@jbrowse/cli 4.3.0 → 5.0.0-beta.10
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 +451 -72
- package/dist/base.js +0 -3
- package/dist/bin.js +2 -1
- package/dist/cliFetch.js +3 -0
- package/dist/commands/add-assembly/index.js +46 -19
- package/dist/commands/add-assembly/utils.js +204 -290
- package/dist/commands/add-connection.js +73 -79
- package/dist/commands/add-track-json.js +24 -12
- package/dist/commands/add-track-utils/adapter-utils.js +189 -288
- package/dist/commands/add-track-utils/file-operations.js +27 -11
- package/dist/commands/add-track-utils/multiwig.js +47 -0
- package/dist/commands/add-track-utils/track-config.js +66 -25
- package/dist/commands/add-track-utils/validators.js +36 -20
- package/dist/commands/add-track.js +200 -62
- package/dist/commands/admin-server/index.js +39 -50
- package/dist/commands/admin-server/utils.js +238 -126
- package/dist/commands/create.js +18 -34
- package/dist/commands/make-density/density-generator.js +180 -0
- package/dist/commands/make-density/index.js +139 -0
- package/dist/commands/make-pif/index.js +133 -11
- package/dist/commands/make-pif/pif-generator.js +243 -43
- package/dist/commands/process-utils.js +13 -2
- package/dist/commands/remove-track.js +20 -13
- package/dist/commands/set-default-session.js +35 -31
- package/dist/commands/shared/config-operations.js +3 -10
- package/dist/commands/shared/density.js +12 -0
- package/dist/commands/shared/sort-utils.js +67 -10
- package/dist/commands/shared/validators.js +38 -3
- package/dist/commands/sort-bed.js +2 -33
- package/dist/commands/sort-gff.js +2 -33
- package/dist/commands/text-index/adapter-utils.js +19 -26
- package/dist/commands/text-index/aggregate.js +40 -34
- package/dist/commands/text-index/command.js +37 -7
- package/dist/commands/text-index/config-utils.js +62 -85
- package/dist/commands/text-index/file-list.js +32 -18
- package/dist/commands/text-index/index.js +4 -4
- package/dist/commands/text-index/indexing-utils.js +44 -96
- package/dist/commands/text-index/per-track.js +44 -31
- package/dist/commands/text-index/validators.js +1 -1
- package/dist/commands/upgrade.js +20 -32
- package/dist/commands/validate/configManifest.generated.js +7613 -0
- package/dist/commands/validate/configSchema.generated.js +20400 -0
- package/dist/commands/validate/displayDefaultKeys.js +28 -0
- package/dist/commands/validate/index.js +113 -0
- package/dist/commands/validate/liftConfig.js +34 -0
- package/dist/commands/validate/markRules/aggregateFieldName.js +9 -0
- package/dist/commands/validate/markRules/autoBin.js +27 -0
- package/dist/commands/validate/markRules/colorScale.js +244 -0
- package/dist/commands/validate/markRules/cssColorsLevel4.js +162 -0
- package/dist/commands/validate/markRules/jexlStrings.js +23 -0
- package/dist/commands/validate/markRules/markProblems.js +532 -0
- package/dist/commands/validate/markRules/markRuleFacts.js +6 -0
- package/dist/commands/validate/markRules/markSpecs.js +96 -0
- package/dist/commands/validate/markRules/markVocabulary.js +57 -0
- package/dist/commands/validate/markRules/shapeNameList.js +7 -0
- package/dist/commands/validate/markRules/stepChannels.js +55 -0
- package/dist/commands/validate/schemaValidate.js +425 -0
- package/dist/commands/validate/suggest.js +35 -0
- package/dist/commands/validate/types.js +6 -0
- package/dist/commands/validate/validateConfig.js +502 -0
- package/dist/index.js +104 -50
- package/dist/types/common.js +3 -27
- package/dist/utils.js +309 -128
- package/dist/version.js +1 -1
- package/package.json +8 -14
- package/bin/run +0 -4
- package/bundle/index.js +0 -3686
- package/dist/commands/make-pif/cigar-utils.js +0 -24
- package/dist/commands/make-pif/file-utils.js +0 -30
- package/dist/commands/track-utils.js +0 -74
- package/dist/util.js +0 -35
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// Checks a JBrowse config (or a saved session file): the generated JSON Schema
|
|
2
|
+
// first, which reports every key or value the schema can judge, then the
|
|
3
|
+
// cross-references a schema cannot express and the warnings for stale
|
|
4
|
+
// spellings a migration still rewrites.
|
|
5
|
+
//
|
|
6
|
+
// Why a validator at all, given MST type-checks a config on load: MST models
|
|
7
|
+
// ignore snapshot keys they do not declare, so a misspelt slot leaves the track
|
|
8
|
+
// loading normally with the setting doing nothing.
|
|
9
|
+
//
|
|
10
|
+
// Pure: no filesystem, no process exit. The command wrapper owns both.
|
|
11
|
+
import { configManifest } from './configManifest.generated.js';
|
|
12
|
+
import { displayDefaultsForTrackType } from './displayDefaultKeys.js';
|
|
13
|
+
import { isRecord, liftToSnapshot } from './liftConfig.js';
|
|
14
|
+
import { colorProblems, scaleEndProblems } from './markRules/colorScale.js';
|
|
15
|
+
import { markProblems } from './markRules/markProblems.js';
|
|
16
|
+
import { hasDeclaredShape, rootConfigurationSlots, schemaProblems, } from './schemaValidate.js';
|
|
17
|
+
import { didYouMean } from './suggest.js';
|
|
18
|
+
class Report {
|
|
19
|
+
problems = [];
|
|
20
|
+
notes = [];
|
|
21
|
+
error(where, message) {
|
|
22
|
+
this.problems.push({ level: 'error', where, message });
|
|
23
|
+
}
|
|
24
|
+
warn(where, message) {
|
|
25
|
+
this.problems.push({ level: 'warning', where, message });
|
|
26
|
+
}
|
|
27
|
+
note(message) {
|
|
28
|
+
this.notes.push(message);
|
|
29
|
+
}
|
|
30
|
+
result() {
|
|
31
|
+
return {
|
|
32
|
+
problems: this.problems,
|
|
33
|
+
notes: this.notes,
|
|
34
|
+
errorCount: this.problems.filter(p => p.level === 'error').length,
|
|
35
|
+
warningCount: this.problems.filter(p => p.level === 'warning').length,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// A declared `sequenceAdapter` works and is simply the wrong way round: every
|
|
40
|
+
// feature adapter takes its sequence from the assembly the track is displayed
|
|
41
|
+
// against, so a hand-written one pins the track to that source. An undeclared
|
|
42
|
+
// one is the schema's unknown-key error, with its own message there.
|
|
43
|
+
const SELF_SUPPLIED = {
|
|
44
|
+
sequenceAdapter: '`sequenceAdapter` is set by hand. JBrowse takes the sequence from the assembly the track is displayed against, so this is only needed to read some OTHER sequence — and it pins the track to that source even when the assembly changes. Delete it unless that is what you meant',
|
|
45
|
+
};
|
|
46
|
+
// The warnings a slot table carries: a legacy key or value a migration
|
|
47
|
+
// rewrites, and a declared key JBrowse fills in for itself.
|
|
48
|
+
function warnSlots(obj, entry, where, report) {
|
|
49
|
+
const declared = new Set(entry.slots.map(s => s.name));
|
|
50
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
51
|
+
if (declared.has(key) && SELF_SUPPLIED[key]) {
|
|
52
|
+
report.warn(`${where}.${key}`, SELF_SUPPLIED[key]);
|
|
53
|
+
}
|
|
54
|
+
else if (entry.legacyValues?.[key]?.includes(value)) {
|
|
55
|
+
report.warn(`${where}.${key}`, `${JSON.stringify(value)} is a legacy value of "${key}" that a migration rewrites — the config loads, but the current spelling is one of the enum's members`);
|
|
56
|
+
}
|
|
57
|
+
else if (!declared.has(key) && entry.legacyKeys?.includes(key)) {
|
|
58
|
+
report.warn(`${where}.${key}`, `"${key}" is a legacy key that a migration rewrites into current slots — the config loads, but check the settings inside it landed`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const slot of entry.slots) {
|
|
62
|
+
const value = obj[slot.name];
|
|
63
|
+
if (!slot.subSlots) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const [item, suffix] of isRecord(value)
|
|
67
|
+
? [[value, '']]
|
|
68
|
+
: Array.isArray(value)
|
|
69
|
+
? value.map((v, i) => [v, `[${i}]`])
|
|
70
|
+
: []) {
|
|
71
|
+
if (isRecord(item)) {
|
|
72
|
+
warnSlots(item, { slots: slot.subSlots }, `${where}.${slot.name}${suffix}`, report);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Resolves a `type` against one of the manifest's groups. An unregistered type
|
|
78
|
+
// is a warning: MST is loud about it on load, and a plugin registers types the
|
|
79
|
+
// manifest never saw.
|
|
80
|
+
function resolveType(obj, group, groupLabel, where, report) {
|
|
81
|
+
const typeName = obj.type;
|
|
82
|
+
if (typeof typeName !== 'string') {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const canonical = typeName in group
|
|
86
|
+
? typeName
|
|
87
|
+
: Object.keys(group).find(name => group[name].aliases?.includes(typeName));
|
|
88
|
+
const entry = canonical === undefined ? undefined : group[canonical];
|
|
89
|
+
if (entry && canonical !== undefined) {
|
|
90
|
+
return { ...entry, canonicalName: canonical };
|
|
91
|
+
}
|
|
92
|
+
report.warn(where, `${groupLabel} type "${typeName}" is not registered by the core plugins${didYouMean(typeName, Object.keys(group))} — it may come from a plugin`);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function checkPluggable(obj, group, groupLabel, where, report) {
|
|
96
|
+
if (!isRecord(obj)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const entry = resolveType(obj, group, groupLabel, `${where}.type`, report);
|
|
100
|
+
if (entry) {
|
|
101
|
+
warnSlots(obj, entry, where, report);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function checkTrack(track, index, manifest, report, ctx) {
|
|
105
|
+
const where = `tracks[${index}]`;
|
|
106
|
+
const loose = typeof track.uri === 'string' && !('adapter' in track);
|
|
107
|
+
const trackId = track.trackId;
|
|
108
|
+
if (typeof trackId === 'string' && trackId) {
|
|
109
|
+
if (ctx.seenTrackIds.has(trackId)) {
|
|
110
|
+
report.error(`${where}.trackId`, `duplicate trackId "${trackId}" — a later track with the same id shadows the earlier one`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
ctx.seenTrackIds.add(trackId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const names = track.assemblyNames;
|
|
117
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
118
|
+
if (!loose || ctx.assemblyCount !== 1) {
|
|
119
|
+
report.error(`${where}.assemblyNames`, 'missing "assemblyNames"');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
if (typeof name === 'string' && !ctx.assemblyNames.has(name)) {
|
|
125
|
+
// The connection caveat leads and the spelling guess trails: a
|
|
126
|
+
// connection added at runtime can supply the assembly, and a guess
|
|
127
|
+
// called a working config a typo.
|
|
128
|
+
report.error(`${where}.assemblyNames`, `assembly "${name}" is not defined in this config, and no connection here supplies one — though a connection added at runtime can${didYouMean(name, [...ctx.assemblyNames])}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
checkPluggable(track, manifest.tracks, 'track', where, report);
|
|
133
|
+
if (!loose) {
|
|
134
|
+
checkPluggable(track.adapter, manifest.adapters, 'adapter', `${where}.adapter`, report);
|
|
135
|
+
}
|
|
136
|
+
const textSearching = track.textSearching;
|
|
137
|
+
if (isRecord(textSearching)) {
|
|
138
|
+
checkPluggable(textSearching.textSearchAdapter, manifest.textSearchAdapters, 'text search adapter', `${where}.textSearching.textSearchAdapter`, report);
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(track.displays)) {
|
|
141
|
+
for (const [i, display] of track.displays.entries()) {
|
|
142
|
+
checkPluggable(display, manifest.displays, 'display', `${where}.displays[${i}]`, report);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function checkAssembly(assembly, index, manifest, report) {
|
|
147
|
+
const sequence = assembly.sequence;
|
|
148
|
+
if (isRecord(sequence) && isRecord(sequence.adapter)) {
|
|
149
|
+
checkPluggable(sequence.adapter, manifest.adapters, 'adapter', `assemblies[${index}].sequence.adapter`, report);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A display node inside a session is instantiated by the display's state
|
|
153
|
+
// model; the schema reports a key it does not declare. What remains here is
|
|
154
|
+
// the legacy display-instance key a session migration still lifts.
|
|
155
|
+
function checkSessionDisplay(display, manifest, where, report) {
|
|
156
|
+
const entry = resolveType(display, manifest.displays, 'display', `${where}.type`, report);
|
|
157
|
+
if (!entry?.stateModelProps) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const migrated = new Set([
|
|
161
|
+
...(manifest.migratedDisplayKeys['*'] ?? []),
|
|
162
|
+
...(manifest.migratedDisplayKeys[entry.canonicalName] ?? []),
|
|
163
|
+
]);
|
|
164
|
+
for (const key of Object.keys(display)) {
|
|
165
|
+
if (!entry.stateModelProps.includes(key) && migrated.has(key)) {
|
|
166
|
+
report.warn(`${where}.${key}`, `"${key}" is a legacy display-instance key that a session migration lifts onto the config slot replacing it — it still works, but writing the current slot is clearer`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Walks the tracks of a view and its sub-views (a synteny view holds a row of
|
|
171
|
+
// LGVs, each with tracks of its own).
|
|
172
|
+
function checkSessionViewTracks(view, manifest, where, report) {
|
|
173
|
+
if (!isRecord(view)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(view.tracks)) {
|
|
177
|
+
for (const [i, track] of view.tracks.entries()) {
|
|
178
|
+
if (!isRecord(track) || !Array.isArray(track.displays)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const [j, display] of track.displays.entries()) {
|
|
182
|
+
if (isRecord(display)) {
|
|
183
|
+
checkSessionDisplay(display, manifest, `${where}.tracks[${i}].displays[${j}]`, report);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(view.views)) {
|
|
189
|
+
for (const [i, sub] of view.views.entries()) {
|
|
190
|
+
checkSessionViewTracks(sub, manifest, `${where}.views[${i}]`, report);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// The ids a view names, which nothing validates at load: a trackId that does not
|
|
195
|
+
// exist simply fails to open, which reads as a rendering bug rather than a typo.
|
|
196
|
+
function checkViewReferences(view, where, report, ctx) {
|
|
197
|
+
const assembly = view.assembly;
|
|
198
|
+
if (typeof assembly === 'string' && !ctx.assemblyNames.has(assembly)) {
|
|
199
|
+
report.error(`${where}.assembly`, `assembly "${assembly}" is not defined in this config${didYouMean(assembly, [...ctx.assemblyNames])}`);
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(view.tracks)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// A LinearSyntenyView's `tracks` is one array per level, so flatten one level
|
|
205
|
+
// before reading entries.
|
|
206
|
+
const entries = view.tracks.flatMap((t) => Array.isArray(t) ? t : [t]);
|
|
207
|
+
for (const [j, entry] of entries.entries()) {
|
|
208
|
+
// A built track snapshot names its config through `configuration` rather
|
|
209
|
+
// than `trackId`, and is state MST restores rather than a reference to open.
|
|
210
|
+
if (isRecord(entry) && !('trackId' in entry)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const trackId = isRecord(entry) ? entry.trackId : entry;
|
|
214
|
+
if (typeof trackId === 'string' &&
|
|
215
|
+
!ctx.seenTrackIds.has(trackId) &&
|
|
216
|
+
!ctx.sequenceTrackIds.has(trackId)) {
|
|
217
|
+
const message = `trackId "${trackId}" is not defined in this config${didYouMean(trackId, [...ctx.seenTrackIds, ...ctx.sequenceTrackIds])}`;
|
|
218
|
+
if (ctx.declaresPlugins) {
|
|
219
|
+
report.warn(`${where}.tracks[${j}]`, `${message} — a plugin it declares may create it`);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
report.error(`${where}.tracks[${j}]`, message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function checkSessionView(view, manifest, where, report, ctx) {
|
|
228
|
+
const entry = resolveType(view, manifest.views, 'view', `${where}.type`, report);
|
|
229
|
+
if (!entry) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
checkViewReferences(view, where, report, ctx);
|
|
233
|
+
// A warning, not an error: v5 unwraps the nesting on the way in, so the
|
|
234
|
+
// settings do arrive.
|
|
235
|
+
if (view.init !== undefined) {
|
|
236
|
+
report.warn(`${where}.init`, 'settings nested under "init", which is deprecated: write every setting directly on the view object');
|
|
237
|
+
if (isRecord(view.init)) {
|
|
238
|
+
checkViewReferences(view.init, `${where}.init`, report, ctx);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// A synteny or breakpoint row is a whole view snapshot of its own. A row
|
|
242
|
+
// written as a recipe names no view type, and which one it becomes is the
|
|
243
|
+
// parent launcher's business.
|
|
244
|
+
if (Array.isArray(view.views)) {
|
|
245
|
+
for (const [i, row] of view.views.entries()) {
|
|
246
|
+
if (isRecord(row) && typeof row.type === 'string') {
|
|
247
|
+
checkSessionView(row, manifest, `${where}.views[${i}]`, report, ctx);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function checkSession(session, manifest, report, ctx) {
|
|
253
|
+
if (!isRecord(session) || !Array.isArray(session.views)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (const [i, view] of session.views.entries()) {
|
|
257
|
+
checkSessionViewTracks(view, manifest, `defaultSession.views[${i}]`, report);
|
|
258
|
+
}
|
|
259
|
+
for (const [i, view] of session.views.entries()) {
|
|
260
|
+
if (isRecord(view)) {
|
|
261
|
+
checkSessionView(view, manifest, `defaultSession.views[${i}]`, report, ctx);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const MARK_DISPLAY = 'LinearMarkDisplay';
|
|
266
|
+
const SLOTS_POINTER = `/$defs/${MARK_DISPLAY}Slots/properties`;
|
|
267
|
+
// The rule list reads a config snapshot, which is what a file is once the
|
|
268
|
+
// schema passes its keys and types and the manifest's lifts are applied. An
|
|
269
|
+
// entry the schema refuses is the schema's to report, and stands as a gap so
|
|
270
|
+
// its siblings keep their indices and their own reports. A list the file
|
|
271
|
+
// leaves unwritten is the slot's default entries, a display's default plot.
|
|
272
|
+
function declaredEntries(lifted, display, slot, slots) {
|
|
273
|
+
const list = lifted[slot];
|
|
274
|
+
const written = display[slot];
|
|
275
|
+
if (!Array.isArray(written)) {
|
|
276
|
+
return (slots.find(s => s.name === slot)?.defaultEntries ?? []);
|
|
277
|
+
}
|
|
278
|
+
return Array.isArray(list)
|
|
279
|
+
? list.map((entry, i) => hasDeclaredShape(written[i], `${SLOTS_POINTER}/${slot}/items`)
|
|
280
|
+
? entry
|
|
281
|
+
: undefined)
|
|
282
|
+
: [];
|
|
283
|
+
}
|
|
284
|
+
function slotPath(slot) {
|
|
285
|
+
return slot.replaceAll(/\.(\d+)(?=\.|$)/g, '[$1]');
|
|
286
|
+
}
|
|
287
|
+
// The display types that read a marks list: the mark display and every one
|
|
288
|
+
// built on it, which the manifest tells apart by the `marks` slot each lists.
|
|
289
|
+
function isMarkDisplayType(type, manifest) {
|
|
290
|
+
return (typeof type === 'string' &&
|
|
291
|
+
!!manifest.displays[type]?.slots.some(slot => slot.name === 'marks'));
|
|
292
|
+
}
|
|
293
|
+
// A mark display's own entry, whose marks may be its type's default plot, or
|
|
294
|
+
// a `displayDefaults` holding marks. A mark is a record with a `transform` of
|
|
295
|
+
// its own, so an untyped node counts only by its `marks`.
|
|
296
|
+
function holdsMarkRules(node, manifest) {
|
|
297
|
+
return (isMarkDisplayType(node.type, manifest) ||
|
|
298
|
+
(node.type === undefined && Array.isArray(node.marks)));
|
|
299
|
+
}
|
|
300
|
+
// What the marks of one display say together that it cannot draw as written:
|
|
301
|
+
// the rule list the display shows in its corner notice, copied from the plugin
|
|
302
|
+
// (`markRules/`). A single mark's `requires` is the schema's to report.
|
|
303
|
+
function checkMarkDisplay(display, manifest, where, report) {
|
|
304
|
+
const type = typeof display.type === 'string' ? display.type : MARK_DISPLAY;
|
|
305
|
+
const slots = manifest.displays[type]?.slots;
|
|
306
|
+
const lifted = liftToSnapshot(display, slots ?? []);
|
|
307
|
+
if (!slots || !isRecord(lifted)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const { facet, rows, scales } = lifted;
|
|
311
|
+
for (const { level, rule, mark, slot, message } of markProblems({
|
|
312
|
+
marks: declaredEntries(lifted, display, 'marks', slots),
|
|
313
|
+
facet: isRecord(facet) ? facet : undefined,
|
|
314
|
+
transform: declaredEntries(lifted, display, 'transform', slots),
|
|
315
|
+
rows: isRecord(rows) ? rows : undefined,
|
|
316
|
+
scales: isRecord(scales) ? scalesOf(scales) : undefined,
|
|
317
|
+
})) {
|
|
318
|
+
pushOnce(report, {
|
|
319
|
+
level,
|
|
320
|
+
where: `${where}${mark === undefined ? '' : `.marks[${mark}]`}.${slotPath(slot)}`,
|
|
321
|
+
message,
|
|
322
|
+
rule,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// A problem the colour and scale rules and the mark rule list both reach, such
|
|
327
|
+
// as scales.y's ends on a mark display, is reported once.
|
|
328
|
+
function pushOnce(report, problem) {
|
|
329
|
+
if (!report.problems.some(p => p.where === problem.where && p.rule === problem.rule)) {
|
|
330
|
+
report.problems.push(problem);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function numberOf(value) {
|
|
334
|
+
return typeof value === 'number' ? value : undefined;
|
|
335
|
+
}
|
|
336
|
+
function scaleEndsOf(scale) {
|
|
337
|
+
return {
|
|
338
|
+
domainMin: numberOf(scale.domainMin),
|
|
339
|
+
domainMax: numberOf(scale.domainMax),
|
|
340
|
+
domainQuantile: numberOf(scale.domainQuantile),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function scalesOf(scales) {
|
|
344
|
+
return isRecord(scales.y) ? { y: scaleEndsOf(scales.y) } : {};
|
|
345
|
+
}
|
|
346
|
+
function declaredMembers(written, slot) {
|
|
347
|
+
const declared = new Set((slot.subSlots ?? []).map(s => s.name));
|
|
348
|
+
return Object.fromEntries(Object.entries(written).filter(([key]) => declared.has(key)));
|
|
349
|
+
}
|
|
350
|
+
// What the display's colour objects and its value scale say together that it
|
|
351
|
+
// cannot draw as written: its schema's fieldPresets stand in for the plugin
|
|
352
|
+
// code the CLI does not run, and every quantitative scale's ends answer to
|
|
353
|
+
// one rule.
|
|
354
|
+
function checkScaleSlots(display, slots, where, report) {
|
|
355
|
+
const lifted = liftToSnapshot(display, slots);
|
|
356
|
+
if (!isRecord(lifted)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const warn = (at, { rule, slot, message }) => {
|
|
360
|
+
pushOnce(report, {
|
|
361
|
+
level: 'warning',
|
|
362
|
+
where: `${at}.${slot}`,
|
|
363
|
+
message,
|
|
364
|
+
rule,
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
for (const slot of slots) {
|
|
368
|
+
const written = lifted[slot.name];
|
|
369
|
+
if (!isRecord(written)) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const members = declaredMembers(written, slot);
|
|
373
|
+
if (slot.fieldPresets) {
|
|
374
|
+
for (const problem of colorProblems({
|
|
375
|
+
...scaleEndsOf(members),
|
|
376
|
+
field: typeof members.field === 'string' ? members.field : '',
|
|
377
|
+
scale: typeof members.scale === 'string' ? members.scale : undefined,
|
|
378
|
+
domain: Array.isArray(members.domain) ? members.domain : undefined,
|
|
379
|
+
range: Array.isArray(members.range) ? members.range : undefined,
|
|
380
|
+
labels: Array.isArray(members.labels) ? members.labels : undefined,
|
|
381
|
+
}, slot.fieldPresets)) {
|
|
382
|
+
warn(`${where}.${slot.name}`, problem);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (slot.name === 'scales' && isRecord(members.y)) {
|
|
386
|
+
for (const problem of scaleEndProblems(scaleEndsOf(members.y))) {
|
|
387
|
+
warn(`${where}.scales.y`, problem);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// A marks list sits wherever a config takes a mark display's slots: a
|
|
393
|
+
// track's `displays` entry, its `displayDefaults`, a session's inline track
|
|
394
|
+
// entry. Only a mark display declares `marks`, so the walk is over the whole
|
|
395
|
+
// file, past a place where the schema refused the key: a session display node
|
|
396
|
+
// drops it.
|
|
397
|
+
function checkMarkDisplays(node, manifest, where, report) {
|
|
398
|
+
if (Array.isArray(node)) {
|
|
399
|
+
for (const [i, item] of node.entries()) {
|
|
400
|
+
checkMarkDisplays(item, manifest, `${where}[${i}]`, report);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (isRecord(node)) {
|
|
404
|
+
const slots = typeof node.type === 'string'
|
|
405
|
+
? manifest.displays[node.type]?.slots
|
|
406
|
+
: undefined;
|
|
407
|
+
if (slots) {
|
|
408
|
+
checkScaleSlots(node, slots, where, report);
|
|
409
|
+
}
|
|
410
|
+
if (typeof node.type === 'string' && isRecord(node.displayDefaults)) {
|
|
411
|
+
for (const display of displayDefaultsForTrackType(node.type, manifest)
|
|
412
|
+
.displayTypes) {
|
|
413
|
+
checkScaleSlots(node.displayDefaults, manifest.displays[display]?.slots ?? [], `${where}.displayDefaults`, report);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (holdsMarkRules(node, manifest) &&
|
|
417
|
+
!report.problems.some(p => p.where === `${where}.marks`)) {
|
|
418
|
+
checkMarkDisplay(node, manifest, where, report);
|
|
419
|
+
}
|
|
420
|
+
for (const [key, member] of Object.entries(node)) {
|
|
421
|
+
checkMarkDisplays(member, manifest, where ? `${where}.${key}` : key, report);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
export function validateConfig(config, manifest = configManifest) {
|
|
426
|
+
const report = new Report();
|
|
427
|
+
if (!isRecord(config)) {
|
|
428
|
+
report.error('', 'config is not a JSON object');
|
|
429
|
+
return report.result();
|
|
430
|
+
}
|
|
431
|
+
const declaresPlugins = Array.isArray(config.plugins) && config.plugins.length > 0;
|
|
432
|
+
if (declaresPlugins) {
|
|
433
|
+
report.note(`config declares ${config.plugins.length} plugin(s); the types and slots they register are not in the schema and cannot be checked`);
|
|
434
|
+
}
|
|
435
|
+
const { configuration } = config;
|
|
436
|
+
const pluginSlots = declaresPlugins && isRecord(configuration)
|
|
437
|
+
? Object.keys(configuration).filter(k => !rootConfigurationSlots.has(k))
|
|
438
|
+
: [];
|
|
439
|
+
for (const key of pluginSlots) {
|
|
440
|
+
report.warn(`configuration.${key}`, `core declares no "${key}" slot${didYouMean(key, [...rootConfigurationSlots])} — a plugin this config declares may`);
|
|
441
|
+
}
|
|
442
|
+
report.problems.push(...schemaProblems(pluginSlots.length && isRecord(configuration)
|
|
443
|
+
? {
|
|
444
|
+
...config,
|
|
445
|
+
configuration: Object.fromEntries(Object.entries(configuration).filter(([k]) => !pluginSlots.includes(k))),
|
|
446
|
+
}
|
|
447
|
+
: config));
|
|
448
|
+
const assemblies = (Array.isArray(config.assemblies)
|
|
449
|
+
? config.assemblies
|
|
450
|
+
: isRecord(config.assembly)
|
|
451
|
+
? [config.assembly]
|
|
452
|
+
: []).filter(isRecord);
|
|
453
|
+
if (assemblies.length === 0) {
|
|
454
|
+
report.error('assemblies', 'no assemblies — a config needs at least one');
|
|
455
|
+
}
|
|
456
|
+
const ctx = {
|
|
457
|
+
// An assembly's `aliases` are usable wherever its name is.
|
|
458
|
+
assemblyNames: new Set(assemblies
|
|
459
|
+
.flatMap(a => [a.name, ...(Array.isArray(a.aliases) ? a.aliases : [])])
|
|
460
|
+
.filter((n) => typeof n === 'string')),
|
|
461
|
+
assemblyCount: assemblies.length,
|
|
462
|
+
seenTrackIds: new Set(),
|
|
463
|
+
declaresPlugins,
|
|
464
|
+
// Both the written trackId and the one the `{name, uri}` shorthand derives,
|
|
465
|
+
// since a config using the shorthand still gets the derived id at runtime.
|
|
466
|
+
sequenceTrackIds: new Set(assemblies.flatMap(a => {
|
|
467
|
+
const declared = isRecord(a.sequence) ? a.sequence.trackId : undefined;
|
|
468
|
+
return [
|
|
469
|
+
...(typeof declared === 'string' ? [declared] : []),
|
|
470
|
+
...(typeof a.name === 'string'
|
|
471
|
+
? [`${a.name}-ReferenceSequenceTrack`]
|
|
472
|
+
: []),
|
|
473
|
+
];
|
|
474
|
+
})),
|
|
475
|
+
};
|
|
476
|
+
for (const [i, assembly] of assemblies.entries()) {
|
|
477
|
+
checkAssembly(assembly, i, manifest, report);
|
|
478
|
+
}
|
|
479
|
+
const tracks = Array.isArray(config.tracks)
|
|
480
|
+
? config.tracks.filter(isRecord)
|
|
481
|
+
: [];
|
|
482
|
+
for (const [i, track] of tracks.entries()) {
|
|
483
|
+
checkTrack(track, i, manifest, report, ctx);
|
|
484
|
+
}
|
|
485
|
+
const connections = Array.isArray(config.connections)
|
|
486
|
+
? config.connections
|
|
487
|
+
: [];
|
|
488
|
+
for (const [i, connection] of connections.entries()) {
|
|
489
|
+
checkPluggable(connection, manifest.connections, 'connection', `connections[${i}]`, report);
|
|
490
|
+
}
|
|
491
|
+
const aggregate = Array.isArray(config.aggregateTextSearchAdapters)
|
|
492
|
+
? config.aggregateTextSearchAdapters
|
|
493
|
+
: [];
|
|
494
|
+
for (const [i, adapter] of aggregate.entries()) {
|
|
495
|
+
checkPluggable(adapter, manifest.textSearchAdapters, 'text search adapter', `aggregateTextSearchAdapters[${i}]`, report);
|
|
496
|
+
}
|
|
497
|
+
// Tracks are registered before the session is checked, so a session may
|
|
498
|
+
// reference any track in the file regardless of declaration order.
|
|
499
|
+
checkSession(config.defaultSession, manifest, report, ctx);
|
|
500
|
+
checkMarkDisplays(config, manifest, '', report);
|
|
501
|
+
return report.result();
|
|
502
|
+
}
|