@jbrowse/cli 5.0.0-beta.1 → 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 +149 -44
- package/dist/commands/add-track-utils/adapter-utils.js +47 -31
- package/dist/commands/add-track-utils/track-config.js +5 -7
- package/dist/commands/add-track.js +31 -9
- package/dist/commands/admin-server/index.js +13 -5
- package/dist/commands/admin-server/utils.js +8 -6
- 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 +62 -27
- package/dist/commands/make-pif/pif-generator.js +98 -132
- package/dist/commands/shared/density.js +12 -0
- package/dist/commands/text-index/adapter-utils.js +17 -7
- package/dist/commands/text-index/aggregate.js +5 -5
- package/dist/commands/text-index/command.js +3 -1
- package/dist/commands/text-index/config-utils.js +7 -7
- package/dist/commands/text-index/indexing-utils.js +21 -49
- package/dist/commands/validate/configManifest.generated.js +4064 -1845
- package/dist/commands/validate/configSchema.generated.js +20400 -0
- package/dist/commands/validate/index.js +21 -5
- 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/validateConfig.js +304 -259
- package/dist/index.js +5 -0
- package/dist/version.js +1 -1
- package/package.json +6 -10
- package/bin/run +0 -4
- package/bundle/index.js +0 -1569
- package/dist/commands/make-pif/cigar-utils.js +0 -67
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
// Checks a JBrowse config (or a saved session file)
|
|
2
|
-
//
|
|
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.
|
|
3
5
|
//
|
|
4
|
-
// Why
|
|
5
|
-
// ignore snapshot keys they do not declare
|
|
6
|
-
//
|
|
7
|
-
// it — the track loads, appears, and the setting silently does nothing. That is
|
|
8
|
-
// the check no other layer performs, and it is the mistake config authors
|
|
9
|
-
// (human or agent) make most.
|
|
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.
|
|
10
9
|
//
|
|
11
10
|
// Pure: no filesystem, no process exit. The command wrapper owns both.
|
|
12
11
|
import { configManifest } from './configManifest.generated.js';
|
|
13
|
-
import {
|
|
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';
|
|
14
17
|
import { didYouMean } from './suggest.js';
|
|
15
|
-
// ------------------------------------------------------------------ reporting
|
|
16
18
|
class Report {
|
|
17
19
|
problems = [];
|
|
18
20
|
notes = [];
|
|
@@ -34,100 +36,55 @@ class Report {
|
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Keys a schema entry accepts as input: what it declares, plus the shorthands
|
|
42
|
-
// normalizeSnapshot expands — an adapter written with `uri` never mentions
|
|
43
|
-
// `bamLocation`, and both forms are correct.
|
|
44
|
-
function acceptedKeys(entry) {
|
|
45
|
-
return [...entry.slots.map(s => s.name), ...(entry.shorthandKeys ?? [])];
|
|
46
|
-
}
|
|
47
|
-
// A key JBrowse fills in for itself, so writing one by hand is an anti-pattern
|
|
48
|
-
// whether or not the schema happens to declare a slot for it. Both halves get a
|
|
49
|
-
// message that says so, because the generic ones send the author looking for a
|
|
50
|
-
// typo in a key that is spelled correctly.
|
|
51
|
-
//
|
|
52
|
-
// `sequenceAdapter` is the standing case, and it is now uniform:
|
|
53
|
-
// `getFeatureAdapter` and `CoreGetRefNames` prime every feature adapter's
|
|
54
|
-
// `sequenceAdapterConfig` from the assembly the track is displayed against, and
|
|
55
|
-
// `getSequenceSubAdapter` is what every adapter that reads a sequence resolves
|
|
56
|
-
// through. So no track has to carry one — three grape/peach/volvox configs were
|
|
57
|
-
// copying their own assembly's FASTA urls into a GC track, and two hg002 tracks
|
|
58
|
-
// carried one on a `CramAdapter`, which declares no such slot at all.
|
|
59
|
-
//
|
|
60
|
-
// Two levels, and the split is this validator's own rule: an undeclared key does
|
|
61
|
-
// nothing where it is written (error), a declared one works and is simply the
|
|
62
|
-
// wrong way round (warning).
|
|
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.
|
|
63
43
|
const SELF_SUPPLIED = {
|
|
64
|
-
sequenceAdapter:
|
|
65
|
-
undeclared: 'JBrowse takes the sequence from the assembly the track is displayed against, and this adapter declares no `sequenceAdapter` slot, so the one written here is never read — delete it',
|
|
66
|
-
declared: '`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',
|
|
67
|
-
},
|
|
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',
|
|
68
45
|
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
report.warn(`${where}.${key}`, selfSupplied.declared);
|
|
77
|
-
}
|
|
78
|
-
continue;
|
|
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]);
|
|
79
53
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// that "silently does nothing" is true and useless, and a validator that
|
|
83
|
-
// cries wolf on a deliberate annotation is one an author learns to ignore.
|
|
84
|
-
if (/^_+comment/i.test(key)) {
|
|
85
|
-
continue;
|
|
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`);
|
|
86
56
|
}
|
|
87
|
-
|
|
88
|
-
// lifted into current slots, so the config does load — but it is stale, and
|
|
89
|
-
// whether every setting inside it survived the lift is worth a look.
|
|
90
|
-
if (legacyKeys.includes(key)) {
|
|
57
|
+
else if (!declared.has(key) && entry.legacyKeys?.includes(key)) {
|
|
91
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`);
|
|
92
|
-
continue;
|
|
93
59
|
}
|
|
94
|
-
report.error(`${where}.${key}`, SELF_SUPPLIED[key]?.undeclared ??
|
|
95
|
-
`unknown slot "${key}"${didYouMean(key, accepted)} — JBrowse ignores keys it does not declare, so this setting silently does nothing`);
|
|
96
60
|
}
|
|
97
|
-
// Recurse into sub-schemas the config actually filled in. One it omits is
|
|
98
|
-
// fine: the sub-schema supplies its own defaults.
|
|
99
61
|
for (const slot of entry.slots) {
|
|
100
62
|
const value = obj[slot.name];
|
|
101
|
-
if (slot.subSlots
|
|
102
|
-
|
|
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
|
+
}
|
|
103
74
|
}
|
|
104
75
|
}
|
|
105
76
|
}
|
|
106
|
-
// Resolves a `type` against one of the manifest's groups.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// there deliberately:
|
|
110
|
-
//
|
|
111
|
-
// error = JBrowse accepts the config and silently does the wrong thing
|
|
112
|
-
// warning = JBrowse will complain by itself when the config loads
|
|
113
|
-
//
|
|
114
|
-
// An unknown type is loud — MST throws "Unknown track type" on load, so the
|
|
115
|
-
// author finds out either way. It is also frequently not wrong: a plugin
|
|
116
|
-
// registers types this manifest never saw. An unknown *slot* has neither
|
|
117
|
-
// property, which is why that one is the error.
|
|
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.
|
|
118
80
|
function resolveType(obj, group, groupLabel, where, report) {
|
|
119
81
|
const typeName = obj.type;
|
|
120
82
|
if (typeof typeName !== 'string') {
|
|
121
|
-
report.error(where, `missing "type" — expected one of the ${groupLabel} types`);
|
|
122
83
|
return undefined;
|
|
123
84
|
}
|
|
124
85
|
const canonical = typeName in group
|
|
125
86
|
? typeName
|
|
126
|
-
:
|
|
127
|
-
// canonicalizes it before validation and sessionMigrations restores the
|
|
128
|
-
// settings that made the old type distinct, so this is supported config
|
|
129
|
-
// — check it against the type that absorbed it.
|
|
130
|
-
Object.keys(group).find(name => group[name].aliases?.includes(typeName));
|
|
87
|
+
: Object.keys(group).find(name => group[name].aliases?.includes(typeName));
|
|
131
88
|
const entry = canonical === undefined ? undefined : group[canonical];
|
|
132
89
|
if (entry && canonical !== undefined) {
|
|
133
90
|
return { ...entry, canonicalName: canonical };
|
|
@@ -135,65 +92,26 @@ function resolveType(obj, group, groupLabel, where, report) {
|
|
|
135
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`);
|
|
136
93
|
return undefined;
|
|
137
94
|
}
|
|
138
|
-
function checkAdapter(adapter, manifest, where, report) {
|
|
139
|
-
if (!isRecord(adapter)) {
|
|
140
|
-
report.error(where, 'missing "adapter"');
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
const entry = resolveType(adapter, manifest.adapters, 'adapter', `${where}.type`, report);
|
|
144
|
-
if (entry) {
|
|
145
|
-
checkSlots(adapter, entry, where, report);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// A pluggable sub-object written inline: resolve its type against `group`, then
|
|
149
|
-
// check its own slots. `connections`, `aggregateTextSearchAdapters` and a
|
|
150
|
-
// track's `textSearching.textSearchAdapter` are all this shape, and none of
|
|
151
|
-
// them was opened at all — a Trix path typo is a search that returns nothing,
|
|
152
|
-
// which is the class of mistake this command exists to catch.
|
|
153
95
|
function checkPluggable(obj, group, groupLabel, where, report) {
|
|
154
96
|
if (!isRecord(obj)) {
|
|
155
|
-
report.error(where, `${groupLabel} must be an object`);
|
|
156
97
|
return;
|
|
157
98
|
}
|
|
158
99
|
const entry = resolveType(obj, group, groupLabel, `${where}.type`, report);
|
|
159
100
|
if (entry) {
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
function checkDisplayDefaults(defaults, trackEntry, manifest, where, report) {
|
|
164
|
-
if (!isRecord(defaults)) {
|
|
165
|
-
report.error(where, 'displayDefaults must be an object of display settings');
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
const accepted = displayDefaultKeys(trackEntry, manifest);
|
|
169
|
-
if (accepted.length === 0) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
for (const key of Object.keys(defaults)) {
|
|
173
|
-
if (!accepted.includes(key)) {
|
|
174
|
-
report.error(`${where}.${key}`, `no display of this track declares "${key}"${didYouMean(key, accepted)}`);
|
|
175
|
-
}
|
|
101
|
+
warnSlots(obj, entry, where, report);
|
|
176
102
|
}
|
|
177
103
|
}
|
|
178
104
|
function checkTrack(track, index, manifest, report, ctx) {
|
|
179
105
|
const where = `tracks[${index}]`;
|
|
180
|
-
// The loose form: a data file and no adapter. JBrowse infers the track type
|
|
181
|
-
// and adapter from the file's extension at load, so neither is required
|
|
182
|
-
// here, and neither can be checked against the manifest without the format
|
|
183
|
-
// plugins' guessers — the keys written beside `uri` still are.
|
|
184
106
|
const loose = typeof track.uri === 'string' && !('adapter' in track);
|
|
185
|
-
const entry = loose && track.type === undefined
|
|
186
|
-
? undefined
|
|
187
|
-
: resolveType(track, manifest.tracks, 'track', `${where}.type`, report);
|
|
188
107
|
const trackId = track.trackId;
|
|
189
|
-
if (typeof trackId
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
ctx.seenTrackIds.add(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
|
+
}
|
|
197
115
|
}
|
|
198
116
|
const names = track.assemblyNames;
|
|
199
117
|
if (!Array.isArray(names) || names.length === 0) {
|
|
@@ -204,120 +122,49 @@ function checkTrack(track, index, manifest, report, ctx) {
|
|
|
204
122
|
else {
|
|
205
123
|
for (const name of names) {
|
|
206
124
|
if (typeof name === 'string' && !ctx.assemblyNames.has(name)) {
|
|
207
|
-
// The connection caveat leads and the spelling guess trails
|
|
208
|
-
// connection
|
|
209
|
-
//
|
|
210
|
-
// and `did you mean "volvox_del"?` called a working config a typo.
|
|
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.
|
|
211
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])}`);
|
|
212
129
|
}
|
|
213
130
|
}
|
|
214
131
|
}
|
|
215
|
-
|
|
216
|
-
// displayDefaults is a shorthand no track schema declares, so it is
|
|
217
|
-
// exempted from the track's own slot check and validated on its own terms.
|
|
218
|
-
checkSlots(track, {
|
|
219
|
-
...entry,
|
|
220
|
-
shorthandKeys: [
|
|
221
|
-
...(entry.shorthandKeys ?? []),
|
|
222
|
-
'displayDefaults',
|
|
223
|
-
...(loose ? ['uri', 'index'] : []),
|
|
224
|
-
],
|
|
225
|
-
}, where, report);
|
|
226
|
-
if (track.displayDefaults !== undefined) {
|
|
227
|
-
checkDisplayDefaults(track.displayDefaults, entry, manifest, `${where}.displayDefaults`, report);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
132
|
+
checkPluggable(track, manifest.tracks, 'track', where, report);
|
|
230
133
|
if (!loose) {
|
|
231
|
-
|
|
134
|
+
checkPluggable(track.adapter, manifest.adapters, 'adapter', `${where}.adapter`, report);
|
|
232
135
|
}
|
|
233
136
|
const textSearching = track.textSearching;
|
|
234
|
-
if (isRecord(textSearching)
|
|
235
|
-
textSearching.textSearchAdapter !== undefined) {
|
|
137
|
+
if (isRecord(textSearching)) {
|
|
236
138
|
checkPluggable(textSearching.textSearchAdapter, manifest.textSearchAdapters, 'text search adapter', `${where}.textSearching.textSearchAdapter`, report);
|
|
237
139
|
}
|
|
238
140
|
if (Array.isArray(track.displays)) {
|
|
239
141
|
for (const [i, display] of track.displays.entries()) {
|
|
240
|
-
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const displayWhere = `${where}.displays[${i}]`;
|
|
244
|
-
const displayEntry = resolveType(display, manifest.displays, 'display', `${displayWhere}.type`, report);
|
|
245
|
-
if (displayEntry) {
|
|
246
|
-
checkSlots(display, displayEntry, displayWhere, report);
|
|
247
|
-
}
|
|
142
|
+
checkPluggable(display, manifest.displays, 'display', `${where}.displays[${i}]`, report);
|
|
248
143
|
}
|
|
249
144
|
}
|
|
250
145
|
}
|
|
251
146
|
function checkAssembly(assembly, index, manifest, report) {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
// An assembly has three levels of shorthand, all filled in by
|
|
257
|
-
// assemblyConfigSchema's preProcessSnapshot, and this used to reject every one
|
|
258
|
-
// of them — including `{ name, uri }`, which the cookbook calls the smallest
|
|
259
|
-
// possible config:
|
|
260
|
-
//
|
|
261
|
-
// 1. `{ name, uri }` -> sequence: { adapter: { uri } }
|
|
262
|
-
// 2. `sequence: { adapter: { uri } }` -> adapter type from the extension
|
|
263
|
-
// 3. sequence with no type/trackId -> ReferenceSequenceTrack, derived id
|
|
264
|
-
//
|
|
265
|
-
// Only (2) can't be checked here, and deliberately: the type is guessed
|
|
266
|
-
// through the `Core-guessAdapterForLocation` extension point, and this command
|
|
267
|
-
// loads no plugins on purpose. A `uri` is therefore taken as enough — saying
|
|
268
|
-
// nothing about an adapter we can't name beats calling a working config broken.
|
|
269
|
-
const sequence = assembly.sequence ??
|
|
270
|
-
(typeof assembly.uri === 'string'
|
|
271
|
-
? { adapter: { uri: assembly.uri } }
|
|
272
|
-
: undefined);
|
|
273
|
-
if (!isRecord(sequence)) {
|
|
274
|
-
report.error(`${where}.sequence`, 'missing "sequence" track (or a `uri` naming the sequence file)');
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (isRecord(sequence.adapter) && !('type' in sequence.adapter)) {
|
|
278
|
-
if (typeof sequence.adapter.uri !== 'string') {
|
|
279
|
-
report.error(`${where}.sequence.adapter`, 'adapter has neither a "type" nor a "uri" to infer one from');
|
|
280
|
-
}
|
|
281
|
-
return;
|
|
147
|
+
const sequence = assembly.sequence;
|
|
148
|
+
if (isRecord(sequence) && isRecord(sequence.adapter)) {
|
|
149
|
+
checkPluggable(sequence.adapter, manifest.adapters, 'adapter', `assemblies[${index}].sequence.adapter`, report);
|
|
282
150
|
}
|
|
283
|
-
checkAdapter(sequence.adapter, manifest, `${where}.sequence.adapter`, report);
|
|
284
151
|
}
|
|
285
|
-
// A display node
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// exactly like a misspelling: the session loads, the track appears, the setting
|
|
289
|
-
// does nothing. That is the same silent failure the slot checks above exist for,
|
|
290
|
-
// on the surface people hand-write most, and it is why a whole class of these
|
|
291
|
-
// sat unnoticed in this repo's own fixtures.
|
|
292
|
-
//
|
|
293
|
-
// The advice differs from the unknown-slot case, so the messages do too — a slot
|
|
294
|
-
// is real, it is just in the wrong place, and there are two right places for it.
|
|
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.
|
|
295
155
|
function checkSessionDisplay(display, manifest, where, report) {
|
|
296
156
|
const entry = resolveType(display, manifest.displays, 'display', `${where}.type`, report);
|
|
297
|
-
// A display type the manifest doesn't know (a plugin's) has already been
|
|
298
|
-
// reported as a warning by resolveType; its props are unknowable here.
|
|
299
157
|
if (!entry?.stateModelProps) {
|
|
300
158
|
return;
|
|
301
159
|
}
|
|
302
|
-
const props = entry.stateModelProps;
|
|
303
|
-
const slots = entry.slots.map(slot => slot.name);
|
|
304
160
|
const migrated = new Set([
|
|
305
161
|
...(manifest.migratedDisplayKeys['*'] ?? []),
|
|
306
162
|
...(manifest.migratedDisplayKeys[entry.canonicalName] ?? []),
|
|
307
163
|
]);
|
|
308
164
|
for (const key of Object.keys(display)) {
|
|
309
|
-
if (
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
if (migrated.has(key)) {
|
|
165
|
+
if (!entry.stateModelProps.includes(key) && migrated.has(key)) {
|
|
313
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`);
|
|
314
167
|
}
|
|
315
|
-
else if (slots.includes(key)) {
|
|
316
|
-
report.error(`${where}.${key}`, `"${key}" is a config slot, not a display property, so a session snapshot drops it and the setting silently does nothing — put it on the track's "displays" entry under "tracks", or in a "trackConfigDeltas" entry, instead`);
|
|
317
|
-
}
|
|
318
|
-
else {
|
|
319
|
-
report.error(`${where}.${key}`, `unknown display property "${key}"${didYouMean(key, [...props, ...slots])} — a session snapshot drops keys the display does not declare, so this setting silently does nothing`);
|
|
320
|
-
}
|
|
321
168
|
}
|
|
322
169
|
}
|
|
323
170
|
// Walks the tracks of a view and its sub-views (a synteny view holds a row of
|
|
@@ -344,9 +191,64 @@ function checkSessionViewTracks(view, manifest, where, report) {
|
|
|
344
191
|
}
|
|
345
192
|
}
|
|
346
193
|
}
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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
|
+
}
|
|
350
252
|
function checkSession(session, manifest, report, ctx) {
|
|
351
253
|
if (!isRecord(session) || !Array.isArray(session.views)) {
|
|
352
254
|
return;
|
|
@@ -355,47 +257,194 @@ function checkSession(session, manifest, report, ctx) {
|
|
|
355
257
|
checkSessionViewTracks(view, manifest, `defaultSession.views[${i}]`, report);
|
|
356
258
|
}
|
|
357
259
|
for (const [i, view] of session.views.entries()) {
|
|
358
|
-
if (
|
|
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)) {
|
|
359
370
|
continue;
|
|
360
371
|
}
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
+
}
|
|
366
384
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// Treating those inner arrays as track entries reported every genome row
|
|
371
|
-
// of every synteny demo as a track with no trackId.
|
|
372
|
-
const entries = init.tracks.flatMap((t) => Array.isArray(t) ? t : [t]);
|
|
373
|
-
for (const [j, entry] of entries.entries()) {
|
|
374
|
-
const trackId = typeof entry === 'string'
|
|
375
|
-
? entry
|
|
376
|
-
: isRecord(entry)
|
|
377
|
-
? entry.trackId
|
|
378
|
-
: undefined;
|
|
379
|
-
if (typeof trackId !== 'string') {
|
|
380
|
-
report.error(`${where}.tracks[${j}]`, 'track entry has no trackId');
|
|
381
|
-
}
|
|
382
|
-
else if (!ctx.seenTrackIds.has(trackId) &&
|
|
383
|
-
!ctx.sequenceTrackIds.has(trackId)) {
|
|
384
|
-
report.error(`${where}.tracks[${j}]`, `trackId "${trackId}" is not defined in this config${didYouMean(trackId, [...ctx.seenTrackIds, ...ctx.sequenceTrackIds])}`);
|
|
385
|
-
}
|
|
385
|
+
if (slot.name === 'scales' && isRecord(members.y)) {
|
|
386
|
+
for (const problem of scaleEndProblems(scaleEndsOf(members.y))) {
|
|
387
|
+
warn(`${where}.scales.y`, problem);
|
|
386
388
|
}
|
|
387
389
|
}
|
|
388
390
|
}
|
|
389
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
|
+
}
|
|
390
425
|
export function validateConfig(config, manifest = configManifest) {
|
|
391
426
|
const report = new Report();
|
|
392
427
|
if (!isRecord(config)) {
|
|
393
428
|
report.error('', 'config is not a JSON object');
|
|
394
429
|
return report.result();
|
|
395
430
|
}
|
|
396
|
-
|
|
397
|
-
|
|
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`);
|
|
398
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));
|
|
399
448
|
const assemblies = (Array.isArray(config.assemblies)
|
|
400
449
|
? config.assemblies
|
|
401
450
|
: isRecord(config.assembly)
|
|
@@ -404,24 +453,16 @@ export function validateConfig(config, manifest = configManifest) {
|
|
|
404
453
|
if (assemblies.length === 0) {
|
|
405
454
|
report.error('assemblies', 'no assemblies — a config needs at least one');
|
|
406
455
|
}
|
|
407
|
-
const connections = Array.isArray(config.connections)
|
|
408
|
-
? config.connections
|
|
409
|
-
: [];
|
|
410
456
|
const ctx = {
|
|
411
|
-
// An assembly's `aliases` are usable wherever its name is
|
|
412
|
-
// `assemblyNames: ['vvx']` against an assembly named volvox that lists vvx
|
|
413
|
-
// as an alias. Leaving them out reports working configs as broken.
|
|
457
|
+
// An assembly's `aliases` are usable wherever its name is.
|
|
414
458
|
assemblyNames: new Set(assemblies
|
|
415
459
|
.flatMap(a => [a.name, ...(Array.isArray(a.aliases) ? a.aliases : [])])
|
|
416
460
|
.filter((n) => typeof n === 'string')),
|
|
417
461
|
assemblyCount: assemblies.length,
|
|
418
462
|
seenTrackIds: new Set(),
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
// the
|
|
422
|
-
// reported every such reference as undefined. Both the written trackId and
|
|
423
|
-
// the one the `{name, uri}` shorthand derives, since a config using the
|
|
424
|
-
// shorthand still gets the derived id at runtime.
|
|
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.
|
|
425
466
|
sequenceTrackIds: new Set(assemblies.flatMap(a => {
|
|
426
467
|
const declared = isRecord(a.sequence) ? a.sequence.trackId : undefined;
|
|
427
468
|
return [
|
|
@@ -441,6 +482,9 @@ export function validateConfig(config, manifest = configManifest) {
|
|
|
441
482
|
for (const [i, track] of tracks.entries()) {
|
|
442
483
|
checkTrack(track, i, manifest, report, ctx);
|
|
443
484
|
}
|
|
485
|
+
const connections = Array.isArray(config.connections)
|
|
486
|
+
? config.connections
|
|
487
|
+
: [];
|
|
444
488
|
for (const [i, connection] of connections.entries()) {
|
|
445
489
|
checkPluggable(connection, manifest.connections, 'connection', `connections[${i}]`, report);
|
|
446
490
|
}
|
|
@@ -453,5 +497,6 @@ export function validateConfig(config, manifest = configManifest) {
|
|
|
453
497
|
// Tracks are registered before the session is checked, so a session may
|
|
454
498
|
// reference any track in the file regardless of declaration order.
|
|
455
499
|
checkSession(config.defaultSession, manifest, report, ctx);
|
|
500
|
+
checkMarkDisplays(config, manifest, '', report);
|
|
456
501
|
return report.result();
|
|
457
502
|
}
|