@jbrowse/cli 5.0.0-beta.8 → 5.0.0-beta.9

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.
@@ -34,8 +34,9 @@ function validatorFor(pointer) {
34
34
  validateSchema: false,
35
35
  code: { optimize: 0 },
36
36
  });
37
+ const whole = ajv.getSchema(schemaId) ?? ajv.compile(root);
37
38
  if (pointer === '') {
38
- return ajv.getSchema(schemaId) ?? ajv.compile(root);
39
+ return whole;
39
40
  }
40
41
  const fn = ajv.getSchema(`${schemaId}#${pointer}`);
41
42
  if (!fn) {
@@ -56,7 +57,7 @@ function resolve(schema) {
56
57
  // slot table it intersects through allOf.
57
58
  function acceptedKeys(schema) {
58
59
  const own = Object.keys(schema.properties ?? {});
59
- const composed = (schema.allOf ?? []).flatMap(branch => Object.keys(resolve(branch).properties));
60
+ const composed = (schema.allOf ?? []).flatMap(branch => Object.keys(resolve(branch).properties ?? {}));
60
61
  return [...new Set([...own, ...composed])];
61
62
  }
62
63
  function describe(schema) {
@@ -67,6 +68,9 @@ function describe(schema) {
67
68
  if (name === 'JexlString') {
68
69
  return 'a "jexl:" expression';
69
70
  }
71
+ if (name === 'CssColor') {
72
+ return 'a CSS color (a name like "red", "#rrggbb", "rgb()" or "hsl()")';
73
+ }
70
74
  if (name) {
71
75
  return `a ${name}`;
72
76
  }
@@ -112,6 +116,15 @@ function join(where, key) {
112
116
  function isRecord(value) {
113
117
  return typeof value === 'object' && value !== null && !Array.isArray(value);
114
118
  }
119
+ function jsonTypeOf(data) {
120
+ return Array.isArray(data)
121
+ ? 'array'
122
+ : data === null
123
+ ? 'null'
124
+ : typeof data === 'object'
125
+ ? 'object'
126
+ : typeof data;
127
+ }
115
128
  function typeOf(data) {
116
129
  return isRecord(data) && typeof data.type === 'string' ? data.type : undefined;
117
130
  }
@@ -130,9 +143,23 @@ const viewNames = new Set(defs.View.properties.type.anyOf[0]
130
143
  // so a slot name written there is dropped just like a misspelling — and the
131
144
  // advice differs, because the key is real and there are two right places for
132
145
  // it.
146
+ function listed(keys) {
147
+ return keys.length > 1
148
+ ? `${keys.slice(0, -1).join(', ')} and ${keys.at(-1)}`
149
+ : (keys[0] ?? 'nothing');
150
+ }
151
+ // The keys Vega-Lite and GenomeSpy spell otherwise, by the schema that
152
+ // declares this spelling
153
+ const SPELLED_HERE = {
154
+ Mark: { mark: 'shape' },
155
+ MarkEncoding: { shape: 'glyph' },
156
+ };
133
157
  function unknownKeyMessage(key, schema, accepted) {
134
158
  const title = typeof schema.title === 'string' ? schema.title : '';
135
- const guess = didYouMean(key, accepted);
159
+ const here = SPELLED_HERE[title]?.[key];
160
+ const guess = here
161
+ ? ` — Vega-Lite's "${key}" is spelled "${here}" here`
162
+ : didYouMean(key, accepted);
136
163
  if (key === 'sequenceAdapter' && !title.endsWith('Snapshot')) {
137
164
  return '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';
138
165
  }
@@ -157,11 +184,39 @@ function unknownKeyMessage(key, schema, accepted) {
157
184
  if (title.endsWith('TrackEntry')) {
158
185
  return `"${key}" is neither a config slot nor a property of ${title.replace(/TrackEntry$/, '')}${guess} — the entry's keys are folded onto that display, and one it does not declare silently does nothing`;
159
186
  }
187
+ if (schema['x-closed'] === true) {
188
+ return `unknown slot "${key}"${guess} — ${title} takes ${listed(accepted)}, and JBrowse refuses to load it rather than drop a key it does not declare`;
189
+ }
160
190
  return `unknown slot "${key}"${guess} — JBrowse ignores keys it does not declare, so this setting silently does nothing`;
161
191
  }
162
192
  function isJexl(value) {
163
193
  return typeof value === 'string' && value.startsWith('jexl:');
164
194
  }
195
+ // The keywords a slot declaring no contextVariable fails a `jexl:` string on:
196
+ // a plain string's `not`, a number's `type`, a color's `pattern`, an enum's
197
+ // own, and a union of those
198
+ const REFUSES_CALLBACK = new Set([
199
+ 'not',
200
+ 'type',
201
+ 'pattern',
202
+ 'enum',
203
+ 'const',
204
+ 'anyOf',
205
+ ]);
206
+ function refusedCallback(value) {
207
+ return `takes no "jexl:" callback, and ${JSON.stringify(value)} is one: a slot that takes a callback lists its callback args in the config docs`;
208
+ }
209
+ function isScalar(schema) {
210
+ const resolved = resolve(schema);
211
+ return (resolved.properties === undefined &&
212
+ resolved.items === undefined &&
213
+ resolved.allOf === undefined &&
214
+ resolved.type !== 'object' &&
215
+ resolved.type !== 'array');
216
+ }
217
+ function isJexlBranch(schema) {
218
+ return refName(schema) === 'JexlString';
219
+ }
165
220
  function branchesOf(error) {
166
221
  return error.parentSchema.anyOf;
167
222
  }
@@ -176,6 +231,11 @@ function pickBranch(error, data, errors) {
176
231
  return byType;
177
232
  }
178
233
  }
234
+ const jsonType = jsonTypeOf(data);
235
+ const byJsonType = branches.flatMap((b, i) => (b.type ?? resolve(b).type) === jsonType ? [i] : []);
236
+ if (byJsonType.length === 1) {
237
+ return byJsonType[0];
238
+ }
179
239
  if (isRecord(data)) {
180
240
  const tagged = branches.findIndex(b => 'locationType' in data &&
181
241
  admitsLocation(resolve(b), data.locationType));
@@ -199,6 +259,31 @@ function admitsLocation(schema, locationType) {
199
259
  const prop = schema.properties?.locationType;
200
260
  return prop?.const === locationType;
201
261
  }
262
+ // A schema's `requires` entry, which the generator writes as an `if`/`then`
263
+ // annotated with the entry's id, the slot path it requires and its message.
264
+ function requirementOf(error) {
265
+ const parent = error.parentSchema ?? {};
266
+ const stated = parent['x-requirement'];
267
+ return error.keyword === 'if' && isRecord(stated)
268
+ ? {
269
+ id: String(stated.id),
270
+ slot: String(stated.slot),
271
+ message: String(parent.errorMessage),
272
+ branch: `${error.schemaPath.replace(/\/if$/, '')}/then/`,
273
+ }
274
+ : undefined;
275
+ }
276
+ // Whether a failure is one requirement's: the `if` itself, or an error under
277
+ // its `then` at or beneath the object the `if` judged.
278
+ function statesRequirement(error, errors) {
279
+ return errors.some(other => {
280
+ const requirement = requirementOf(other);
281
+ return (requirement !== undefined &&
282
+ (other === error ||
283
+ (error.schemaPath.startsWith(requirement.branch) &&
284
+ error.instancePath.startsWith(other.instancePath))));
285
+ });
286
+ }
202
287
  function requiredOnly(branches) {
203
288
  return branches.every(b => Object.keys(b).length === 1 && Array.isArray(b.required));
204
289
  }
@@ -207,25 +292,48 @@ function requiredOnly(branches) {
207
292
  // than one per alternative.
208
293
  function explain(data, errors, problems, depth) {
209
294
  const covered = new Set();
210
- const emit = (where, message) => {
211
- const key = `${where}${message}`;
295
+ const emit = (where, message, rule) => {
296
+ const key = `${where}\0${message}`;
212
297
  if (!covered.has(key)) {
213
298
  covered.add(key);
214
- problems.push({ level: 'error', where, message });
299
+ problems.push({
300
+ level: 'error',
301
+ where,
302
+ message,
303
+ ...(rule ? { rule } : {}),
304
+ });
215
305
  }
216
306
  };
217
307
  // A union failure explains everything beneath it; the branch is re-validated
218
308
  // on its own so the sub-errors reported for the other branches drop out.
309
+ // A union nested as another's branch at the same path (`number | "auto"`
310
+ // inside a slot's `value | jexl:`) is the outer one's to explain, and never
311
+ // the other way round, or neither is reported.
219
312
  const unions = errors.filter(e => e.keyword === 'anyOf');
313
+ const isBranchOf = (u, e) => branchesOf(u).some(b => resolve(b) === e.parentSchema);
220
314
  const shadowed = (e) => unions.some(u => u !== e &&
221
- (e.instancePath === u.instancePath ||
222
- e.instancePath.startsWith(`${u.instancePath}/`)));
315
+ (e.instancePath.startsWith(`${u.instancePath}/`) ||
316
+ (e.instancePath === u.instancePath &&
317
+ !(e.keyword === 'anyOf' && isBranchOf(e, u)))));
223
318
  for (const error of errors) {
224
- if (shadowed(error) || error.keyword === 'if') {
319
+ if (shadowed(error)) {
225
320
  continue;
226
321
  }
227
322
  const where = pathOf(error.instancePath);
323
+ const requirement = requirementOf(error);
324
+ if (requirement) {
325
+ emit(join(where, requirement.slot), requirement.message, requirement.id);
326
+ }
327
+ if (error.keyword === 'if' || statesRequirement(error, errors)) {
328
+ continue;
329
+ }
228
330
  const parent = error.parentSchema;
331
+ if (isJexl(error.data) &&
332
+ REFUSES_CALLBACK.has(error.keyword) &&
333
+ !(error.keyword === 'anyOf' && branchesOf(error).some(isJexlBranch))) {
334
+ emit(where, refusedCallback(error.data));
335
+ continue;
336
+ }
229
337
  switch (error.keyword) {
230
338
  case 'additionalProperties':
231
339
  case 'unevaluatedProperties': {
@@ -243,12 +351,18 @@ function explain(data, errors, problems, depth) {
243
351
  emit(where, `needs one of ${branches.map(b => `"${b.required[0]}"`).join(', ')}`);
244
352
  break;
245
353
  }
246
- const jexlBranch = branches.findIndex(b => refName(b) === 'JexlString');
354
+ const jexlBranch = branches.findIndex(isJexlBranch);
247
355
  if (jexlBranch >= 0 && !isJexl(value)) {
248
356
  const others = branches.filter((_, i) => i !== jexlBranch);
249
357
  emit(where, `expected ${others.map(describe).join(' or ')} or a "jexl:" expression, got ${JSON.stringify(value)}`);
250
358
  break;
251
359
  }
360
+ if (!isRecord(value) &&
361
+ !Array.isArray(value) &&
362
+ branches.every(isScalar)) {
363
+ emit(where, `expected ${branches.map(describe).join(' or ')}, got ${JSON.stringify(value)}`);
364
+ break;
365
+ }
252
366
  if (depth > 8) {
253
367
  emit(where, `does not match any accepted form`);
254
368
  break;
@@ -272,10 +386,14 @@ function explain(data, errors, problems, depth) {
272
386
  }
273
387
  break;
274
388
  }
389
+ case 'pattern':
390
+ emit(where, error.schemaPath.endsWith('/CssColor/pattern')
391
+ ? `expected ${describe({ $ref: '#/$defs/CssColor' })}, got ${JSON.stringify(error.data)}`
392
+ : `expected a string matching /${String(parent.pattern)}/, got ${JSON.stringify(error.data)}`);
393
+ break;
275
394
  case 'type':
276
395
  case 'enum':
277
396
  case 'const':
278
- case 'pattern':
279
397
  emit(where, `expected ${describe(parent)}, got ${JSON.stringify(error.data)}`);
280
398
  break;
281
399
  case 'not':
@@ -297,4 +415,20 @@ export function schemaProblems(data, pointer = '') {
297
415
  explain(data, validate.errors ?? [], problems, 0);
298
416
  return problems;
299
417
  }
418
+ /**
419
+ * Whether `data` has the keys and value types the definition at `pointer`
420
+ * declares, whatever its `requires` entries say of it: the contract a rule
421
+ * list reads a config under, since a load refuses a key or a type and no
422
+ * combination of slots.
423
+ */
424
+ export function hasDeclaredShape(data, pointer) {
425
+ const validate = validatorFor(pointer);
426
+ const errors = validate(data) ? [] : (validate.errors ?? []);
427
+ return errors.every(error => statesRequirement(error, errors));
428
+ }
429
+ /**
430
+ * The keys core declares directly under `configuration`. A plugin adds its own
431
+ * beside them, the way `LinearGenomeViewPlugin` is core's.
432
+ */
433
+ export const rootConfigurationSlots = new Set(Object.keys(defs.RootConfiguration.properties));
300
434
  export { schemaId as configSchemaUrl };
@@ -9,7 +9,11 @@
9
9
  //
10
10
  // Pure: no filesystem, no process exit. The command wrapper owns both.
11
11
  import { configManifest } from './configManifest.generated.js';
12
- import { schemaProblems } from './schemaValidate.js';
12
+ import { displayDefaultsForTrackType } from './displayDefaultKeys.js';
13
+ import { isRecord, liftToSnapshot } from './liftConfig.js';
14
+ import { colorProblems, fieldScaleOf } from './markRules/colorScale.js';
15
+ import { markProblems } from './markRules/markProblems.js';
16
+ import { hasDeclaredShape, rootConfigurationSlots, schemaProblems, } from './schemaValidate.js';
13
17
  import { didYouMean } from './suggest.js';
14
18
  class Report {
15
19
  problems = [];
@@ -32,9 +36,6 @@ class Report {
32
36
  };
33
37
  }
34
38
  }
35
- function isRecord(value) {
36
- return typeof value === 'object' && value !== null && !Array.isArray(value);
37
- }
38
39
  // A declared `sequenceAdapter` works and is simply the wrong way round: every
39
40
  // feature adapter takes its sequence from the assembly the track is displayed
40
41
  // against, so a hand-written one pins the track to that source. An undeclared
@@ -213,7 +214,13 @@ function checkViewReferences(view, where, report, ctx) {
213
214
  if (typeof trackId === 'string' &&
214
215
  !ctx.seenTrackIds.has(trackId) &&
215
216
  !ctx.sequenceTrackIds.has(trackId)) {
216
- report.error(`${where}.tracks[${j}]`, `trackId "${trackId}" is not defined in this config${didYouMean(trackId, [...ctx.seenTrackIds, ...ctx.sequenceTrackIds])}`);
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
+ }
217
224
  }
218
225
  }
219
226
  }
@@ -255,16 +262,142 @@ function checkSession(session, manifest, report, ctx) {
255
262
  }
256
263
  }
257
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.
271
+ function declaredEntries(lifted, display, slot) {
272
+ const list = lifted[slot];
273
+ const written = display[slot];
274
+ return Array.isArray(list) && Array.isArray(written)
275
+ ? list.map((entry, i) => hasDeclaredShape(written[i], `${SLOTS_POINTER}/${slot}/items`)
276
+ ? entry
277
+ : undefined)
278
+ : [];
279
+ }
280
+ function slotPath(slot) {
281
+ return slot.replaceAll(/\.(\d+)(?=\.|$)/g, '[$1]');
282
+ }
283
+ // A mark display's own entry, whose marks may come from `displayDefaults`, or
284
+ // a `displayDefaults` holding marks. A mark is a record with a `transform` of
285
+ // its own, so an untyped node counts only by its `marks`.
286
+ function holdsMarkRules(node) {
287
+ return node.type === MARK_DISPLAY
288
+ ? Array.isArray(node.marks) || Array.isArray(node.transform)
289
+ : node.type === undefined && Array.isArray(node.marks);
290
+ }
291
+ // What the marks of one display say together that it cannot draw as written:
292
+ // the rule list the display shows in its corner notice, copied from the plugin
293
+ // (`markRules/`). A single mark's `requires` is the schema's to report.
294
+ function checkMarkDisplay(display, manifest, where, report) {
295
+ const slots = manifest.displays[MARK_DISPLAY]?.slots;
296
+ const lifted = liftToSnapshot(display, slots ?? []);
297
+ if (!slots || !isRecord(lifted)) {
298
+ return;
299
+ }
300
+ const { facet } = lifted;
301
+ for (const { level, rule, mark, slot, message } of markProblems(declaredEntries(lifted, display, 'marks'), isRecord(facet) ? facet : undefined, declaredEntries(lifted, display, 'transform'))) {
302
+ report.problems.push({
303
+ level,
304
+ where: `${where}${mark === undefined ? '' : `.marks[${mark}]`}.${slotPath(slot)}`,
305
+ message,
306
+ rule,
307
+ });
308
+ }
309
+ }
310
+ // The display's own colour rules, with its schema's fieldScale standing in
311
+ // for the plugin code the CLI does not run
312
+ function checkColorSlots(display, slots, where, report) {
313
+ const lifted = liftToSnapshot(display, slots);
314
+ if (!isRecord(lifted)) {
315
+ return;
316
+ }
317
+ for (const { name, fieldScale, subSlots = [] } of slots) {
318
+ const written = lifted[name];
319
+ if (!fieldScale || !isRecord(written)) {
320
+ continue;
321
+ }
322
+ const declared = new Set(subSlots.map(slot => slot.name));
323
+ const color = Object.fromEntries(Object.entries(written).filter(([key]) => declared.has(key)));
324
+ const field = typeof color.field === 'string' ? color.field : '';
325
+ for (const problem of colorProblems({
326
+ field,
327
+ scale: typeof color.scale === 'string' ? color.scale : undefined,
328
+ domain: Array.isArray(color.domain) ? color.domain : undefined,
329
+ range: Array.isArray(color.range) ? color.range : undefined,
330
+ domainMin: typeof color.domainMin === 'number' ? color.domainMin : undefined,
331
+ domainMax: typeof color.domainMax === 'number' ? color.domainMax : undefined,
332
+ }, fieldScaleOf(fieldScale, field))) {
333
+ const at = `${where}.${name}.${problem.slot}`;
334
+ if (!report.problems.some(p => p.where === at && p.rule === problem.rule)) {
335
+ report.problems.push({
336
+ level: 'warning',
337
+ where: at,
338
+ message: problem.message,
339
+ rule: problem.rule,
340
+ });
341
+ }
342
+ }
343
+ }
344
+ }
345
+ // A marks list sits wherever a config takes the mark display's slots: a
346
+ // track's `displays` entry, its `displayDefaults`, a session's inline track
347
+ // entry. No other type declares `marks`, so the walk is over the whole file,
348
+ // past a place where the schema refused the key: a session display node
349
+ // drops it.
350
+ function checkMarkDisplays(node, manifest, where, report) {
351
+ if (Array.isArray(node)) {
352
+ for (const [i, item] of node.entries()) {
353
+ checkMarkDisplays(item, manifest, `${where}[${i}]`, report);
354
+ }
355
+ }
356
+ else if (isRecord(node)) {
357
+ const colorSlots = typeof node.type === 'string'
358
+ ? manifest.displays[node.type]?.slots
359
+ : undefined;
360
+ if (colorSlots) {
361
+ checkColorSlots(node, colorSlots, where, report);
362
+ }
363
+ if (typeof node.type === 'string' && isRecord(node.displayDefaults)) {
364
+ for (const display of displayDefaultsForTrackType(node.type, manifest)
365
+ .displayTypes) {
366
+ checkColorSlots(node.displayDefaults, manifest.displays[display]?.slots ?? [], `${where}.displayDefaults`, report);
367
+ }
368
+ }
369
+ if (holdsMarkRules(node) &&
370
+ !report.problems.some(p => p.where === `${where}.marks`)) {
371
+ checkMarkDisplay(node, manifest, where, report);
372
+ }
373
+ for (const [key, member] of Object.entries(node)) {
374
+ checkMarkDisplays(member, manifest, where ? `${where}.${key}` : key, report);
375
+ }
376
+ }
377
+ }
258
378
  export function validateConfig(config, manifest = configManifest) {
259
379
  const report = new Report();
260
380
  if (!isRecord(config)) {
261
381
  report.error('', 'config is not a JSON object');
262
382
  return report.result();
263
383
  }
264
- if (Array.isArray(config.plugins) && config.plugins.length > 0) {
384
+ const declaresPlugins = Array.isArray(config.plugins) && config.plugins.length > 0;
385
+ if (declaresPlugins) {
265
386
  report.note(`config declares ${config.plugins.length} plugin(s); the types and slots they register are not in the schema and cannot be checked`);
266
387
  }
267
- report.problems.push(...schemaProblems(config));
388
+ const { configuration } = config;
389
+ const pluginSlots = declaresPlugins && isRecord(configuration)
390
+ ? Object.keys(configuration).filter(k => !rootConfigurationSlots.has(k))
391
+ : [];
392
+ for (const key of pluginSlots) {
393
+ report.warn(`configuration.${key}`, `core declares no "${key}" slot${didYouMean(key, [...rootConfigurationSlots])} — a plugin this config declares may`);
394
+ }
395
+ report.problems.push(...schemaProblems(pluginSlots.length && isRecord(configuration)
396
+ ? {
397
+ ...config,
398
+ configuration: Object.fromEntries(Object.entries(configuration).filter(([k]) => !pluginSlots.includes(k))),
399
+ }
400
+ : config));
268
401
  const assemblies = (Array.isArray(config.assemblies)
269
402
  ? config.assemblies
270
403
  : isRecord(config.assembly)
@@ -280,6 +413,7 @@ export function validateConfig(config, manifest = configManifest) {
280
413
  .filter((n) => typeof n === 'string')),
281
414
  assemblyCount: assemblies.length,
282
415
  seenTrackIds: new Set(),
416
+ declaresPlugins,
283
417
  // Both the written trackId and the one the `{name, uri}` shorthand derives,
284
418
  // since a config using the shorthand still gets the derived id at runtime.
285
419
  sequenceTrackIds: new Set(assemblies.flatMap(a => {
@@ -316,5 +450,6 @@ export function validateConfig(config, manifest = configManifest) {
316
450
  // Tracks are registered before the session is checked, so a session may
317
451
  // reference any track in the file regardless of declaration order.
318
452
  checkSession(config.defaultSession, manifest, report, ctx);
453
+ checkMarkDisplays(config, manifest, '', report);
319
454
  return report.result();
320
455
  }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '5.0.0-beta.8';
1
+ export const version = '5.0.0-beta.9';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/cli",
3
- "version": "5.0.0-beta.8",
3
+ "version": "5.0.0-beta.9",
4
4
  "type": "module",
5
5
  "description": "A command line tool for working with JBrowse 2",
6
6
  "keywords": [
@@ -32,9 +32,9 @@
32
32
  "ixixx": "^3.0.13",
33
33
  "serve-handler": "^6.1.7",
34
34
  "tmp": "^0.2.7",
35
- "@jbrowse/add-track-core": "^5.0.0-beta.8",
36
- "@jbrowse/cigar-utils": "^5.0.0-beta.8",
37
- "@jbrowse/text-indexing-core": "^5.0.0-beta.8"
35
+ "@jbrowse/add-track-core": "^5.0.0-beta.9",
36
+ "@jbrowse/text-indexing-core": "^5.0.0-beta.9",
37
+ "@jbrowse/cigar-utils": "^5.0.0-beta.9"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"