@jbrowse/cli 5.0.0-beta.1 → 5.0.0-beta.11

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