@jbrowse/cli 5.0.0-beta.4 → 5.0.0-beta.8
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 +7 -0
- package/dist/commands/add-track-utils/track-config.js +3 -3
- package/dist/commands/make-pif/index.js +6 -1
- package/dist/commands/make-pif/pif-generator.js +34 -40
- package/dist/commands/validate/configManifest.generated.js +685 -75
- package/dist/commands/validate/configSchema.generated.js +17706 -0
- package/dist/commands/validate/index.js +8 -1
- package/dist/commands/validate/schemaValidate.js +300 -0
- package/dist/commands/validate/validateConfig.js +76 -278
- package/dist/version.js +1 -1
- package/package.json +5 -4
|
@@ -19,7 +19,14 @@ Two levels are reported:
|
|
|
19
19
|
nesting its settings under "init". Never fails the run.
|
|
20
20
|
|
|
21
21
|
Types registered by plugins are not known to this command, so they come through
|
|
22
|
-
as warnings rather than errors
|
|
22
|
+
as warnings rather than errors.
|
|
23
|
+
|
|
24
|
+
The same checks are published as a JSON Schema at
|
|
25
|
+
https://jbrowse.org/jb2/schema/v5/config.json: a "$schema" line naming it at
|
|
26
|
+
the top of a config.json gives an editor completion and validation for every
|
|
27
|
+
slot. This command runs that schema first, then the cross-references a schema
|
|
28
|
+
cannot express — a trackId a session names that no track defines, an assembly
|
|
29
|
+
a track names that none defines.`;
|
|
23
30
|
const examples = [
|
|
24
31
|
'# check the config.json in the current directory',
|
|
25
32
|
'$ jbrowse validate',
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Validates a config against the generated JSON Schema and turns each failure
|
|
2
|
+
// into a Problem an author can act on: the path in the config's own spelling,
|
|
3
|
+
// the accepted keys with a did-you-mean, and for a union the errors of the
|
|
4
|
+
// branch the value was written for rather than of every branch.
|
|
5
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
6
|
+
import { configJsonSchema } from './configSchema.generated.js';
|
|
7
|
+
import { didYouMean } from './suggest.js';
|
|
8
|
+
const root = configJsonSchema;
|
|
9
|
+
const defs = root.$defs;
|
|
10
|
+
const schemaId = root.$id;
|
|
11
|
+
// Every object inside the schema, keyed by identity, to its JSON pointer: ajv
|
|
12
|
+
// reports `parentSchema` as the object and its `schemaPath` relative to the
|
|
13
|
+
// nearest $ref target, and a union branch is re-validated by absolute pointer.
|
|
14
|
+
const pointers = new Map();
|
|
15
|
+
function index(node, pointer) {
|
|
16
|
+
if (typeof node !== 'object' || node === null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
pointers.set(node, pointer);
|
|
20
|
+
for (const [key, value] of Object.entries(node)) {
|
|
21
|
+
index(value, `${pointer}/${key.replaceAll('~', '~0').replaceAll('/', '~1')}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
index(root, '');
|
|
25
|
+
let ajv;
|
|
26
|
+
function validatorFor(pointer) {
|
|
27
|
+
// No meta-validation and no codegen optimization: the schema is generated
|
|
28
|
+
// and its own test checks it against the draft, and a config is validated
|
|
29
|
+
// once per process, so the compile is the cost that matters.
|
|
30
|
+
ajv ??= new Ajv2020({
|
|
31
|
+
allErrors: true,
|
|
32
|
+
strict: false,
|
|
33
|
+
verbose: true,
|
|
34
|
+
validateSchema: false,
|
|
35
|
+
code: { optimize: 0 },
|
|
36
|
+
});
|
|
37
|
+
if (pointer === '') {
|
|
38
|
+
return ajv.getSchema(schemaId) ?? ajv.compile(root);
|
|
39
|
+
}
|
|
40
|
+
const fn = ajv.getSchema(`${schemaId}#${pointer}`);
|
|
41
|
+
if (!fn) {
|
|
42
|
+
throw new Error(`no schema at ${pointer}`);
|
|
43
|
+
}
|
|
44
|
+
return fn;
|
|
45
|
+
}
|
|
46
|
+
function refName(schema) {
|
|
47
|
+
return typeof schema?.$ref === 'string'
|
|
48
|
+
? schema.$ref.replace('#/$defs/', '')
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
function resolve(schema) {
|
|
52
|
+
const name = refName(schema);
|
|
53
|
+
return name ? (defs[name] ?? schema) : schema;
|
|
54
|
+
}
|
|
55
|
+
// The keys an object schema accepts: its own properties plus those of every
|
|
56
|
+
// slot table it intersects through allOf.
|
|
57
|
+
function acceptedKeys(schema) {
|
|
58
|
+
const own = Object.keys(schema.properties ?? {});
|
|
59
|
+
const composed = (schema.allOf ?? []).flatMap(branch => Object.keys(resolve(branch).properties));
|
|
60
|
+
return [...new Set([...own, ...composed])];
|
|
61
|
+
}
|
|
62
|
+
function describe(schema) {
|
|
63
|
+
const name = refName(schema);
|
|
64
|
+
if (name === 'FileLocation') {
|
|
65
|
+
return 'a file location ({ "uri": ... } or { "localPath": ... })';
|
|
66
|
+
}
|
|
67
|
+
if (name === 'JexlString') {
|
|
68
|
+
return 'a "jexl:" expression';
|
|
69
|
+
}
|
|
70
|
+
if (name) {
|
|
71
|
+
return `a ${name}`;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(schema.enum)) {
|
|
74
|
+
return `one of ${schema.enum.map(v => JSON.stringify(v)).join(', ')}`;
|
|
75
|
+
}
|
|
76
|
+
if ('const' in schema) {
|
|
77
|
+
return JSON.stringify(schema.const);
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(schema.anyOf)) {
|
|
80
|
+
return schema.anyOf.map(describe).join(' or ');
|
|
81
|
+
}
|
|
82
|
+
switch (schema.type) {
|
|
83
|
+
case 'array':
|
|
84
|
+
return schema.items
|
|
85
|
+
? `an array of ${describe(schema.items)}`
|
|
86
|
+
: 'an array';
|
|
87
|
+
case 'object':
|
|
88
|
+
return 'an object';
|
|
89
|
+
case 'string':
|
|
90
|
+
return 'a string';
|
|
91
|
+
case 'number':
|
|
92
|
+
case 'integer':
|
|
93
|
+
return 'a number';
|
|
94
|
+
case 'boolean':
|
|
95
|
+
return 'a boolean';
|
|
96
|
+
default:
|
|
97
|
+
return 'a value';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function pathOf(instancePath) {
|
|
101
|
+
return instancePath
|
|
102
|
+
.split('/')
|
|
103
|
+
.slice(1)
|
|
104
|
+
.map(seg => seg.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
105
|
+
.map(seg => (/^\d+$/.test(seg) ? `[${seg}]` : `.${seg}`))
|
|
106
|
+
.join('')
|
|
107
|
+
.replace(/^\./, '');
|
|
108
|
+
}
|
|
109
|
+
function join(where, key) {
|
|
110
|
+
return where && key ? `${where}.${key}` : where || key;
|
|
111
|
+
}
|
|
112
|
+
function isRecord(value) {
|
|
113
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
function typeOf(data) {
|
|
116
|
+
return isRecord(data) && typeof data.type === 'string' ? data.type : undefined;
|
|
117
|
+
}
|
|
118
|
+
// Whether an object schema is for this `type`: a def names it as a const or an
|
|
119
|
+
// alias enum, a dispatching union lists it in its hint.
|
|
120
|
+
function admitsType(schema, type) {
|
|
121
|
+
const prop = schema.properties?.type;
|
|
122
|
+
const hint = prop?.anyOf?.[0];
|
|
123
|
+
return [prop, hint].some(p => p &&
|
|
124
|
+
(p.const === type || (Array.isArray(p.enum) && p.enum.includes(type))));
|
|
125
|
+
}
|
|
126
|
+
const viewNames = new Set(defs.View.properties.type.anyOf[0]
|
|
127
|
+
.enum);
|
|
128
|
+
// What the message says an unknown key is, by the kind of object it sits on.
|
|
129
|
+
// A session node is instantiated by a state model rather than a config schema,
|
|
130
|
+
// so a slot name written there is dropped just like a misspelling — and the
|
|
131
|
+
// advice differs, because the key is real and there are two right places for
|
|
132
|
+
// it.
|
|
133
|
+
function unknownKeyMessage(key, schema, accepted) {
|
|
134
|
+
const title = typeof schema.title === 'string' ? schema.title : '';
|
|
135
|
+
const guess = didYouMean(key, accepted);
|
|
136
|
+
if (key === 'sequenceAdapter' && !title.endsWith('Snapshot')) {
|
|
137
|
+
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
|
+
}
|
|
139
|
+
if (title.endsWith('Snapshot')) {
|
|
140
|
+
const slots = Object.keys(defs[`${title.replace(/Snapshot$/, '')}Slots`]?.properties);
|
|
141
|
+
return slots.includes(key)
|
|
142
|
+
? `"${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`
|
|
143
|
+
: `unknown display property "${key}"${didYouMean(key, [...accepted, ...slots])} — a session snapshot drops keys the display does not declare, so this setting silently does nothing`;
|
|
144
|
+
}
|
|
145
|
+
if (viewNames.has(title)) {
|
|
146
|
+
const elsewhere = [...viewNames].filter(name => name !== title && acceptedKeys(defs[name]).includes(key));
|
|
147
|
+
return elsewhere.length
|
|
148
|
+
? `"${key}" is a setting of ${elsewhere.join(', ')}, not of ${title}${guess} — a session snapshot drops keys the view does not declare, so this setting silently does nothing`
|
|
149
|
+
: `unknown view key "${key}"${guess} — a session snapshot drops keys the view does not declare, so this setting silently does nothing`;
|
|
150
|
+
}
|
|
151
|
+
if (title === 'Session') {
|
|
152
|
+
return `unknown session key "${key}"${guess} — a session snapshot drops keys it does not declare, so this setting silently does nothing`;
|
|
153
|
+
}
|
|
154
|
+
if (title.endsWith('DisplayDefaults')) {
|
|
155
|
+
return `no display of this track declares "${key}"${guess}`;
|
|
156
|
+
}
|
|
157
|
+
if (title.endsWith('TrackEntry')) {
|
|
158
|
+
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
|
+
}
|
|
160
|
+
return `unknown slot "${key}"${guess} — JBrowse ignores keys it does not declare, so this setting silently does nothing`;
|
|
161
|
+
}
|
|
162
|
+
function isJexl(value) {
|
|
163
|
+
return typeof value === 'string' && value.startsWith('jexl:');
|
|
164
|
+
}
|
|
165
|
+
function branchesOf(error) {
|
|
166
|
+
return error.parentSchema.anyOf;
|
|
167
|
+
}
|
|
168
|
+
// Which branch of a union the value was written for: the one whose `type` it
|
|
169
|
+
// names, else the one it comes closest to satisfying.
|
|
170
|
+
function pickBranch(error, data, errors) {
|
|
171
|
+
const branches = branchesOf(error);
|
|
172
|
+
const type = typeOf(data);
|
|
173
|
+
if (type !== undefined) {
|
|
174
|
+
const byType = branches.findIndex(b => admitsType(resolve(b), type));
|
|
175
|
+
if (byType >= 0) {
|
|
176
|
+
return byType;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (isRecord(data)) {
|
|
180
|
+
const tagged = branches.findIndex(b => 'locationType' in data &&
|
|
181
|
+
admitsLocation(resolve(b), data.locationType));
|
|
182
|
+
if (tagged >= 0) {
|
|
183
|
+
return tagged;
|
|
184
|
+
}
|
|
185
|
+
const untagged = branches.findIndex(b => 'not' in resolve(b));
|
|
186
|
+
if (untagged >= 0 && !('locationType' in data)) {
|
|
187
|
+
return untagged;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const counts = branches.map((_, i) => {
|
|
191
|
+
const prefix = `${error.schemaPath}/${i}/`;
|
|
192
|
+
return errors.filter(e => e.schemaPath.startsWith(prefix) &&
|
|
193
|
+
e.instancePath.startsWith(error.instancePath)).length;
|
|
194
|
+
});
|
|
195
|
+
const structural = branches.map((b, i) => counts[i] === 0 ? Infinity : counts[i]);
|
|
196
|
+
return structural.indexOf(Math.min(...structural));
|
|
197
|
+
}
|
|
198
|
+
function admitsLocation(schema, locationType) {
|
|
199
|
+
const prop = schema.properties?.locationType;
|
|
200
|
+
return prop?.const === locationType;
|
|
201
|
+
}
|
|
202
|
+
function requiredOnly(branches) {
|
|
203
|
+
return branches.every(b => Object.keys(b).length === 1 && Array.isArray(b.required));
|
|
204
|
+
}
|
|
205
|
+
// Converts ajv's errors at one validation into Problems, recursing into the
|
|
206
|
+
// chosen branch of each failed union so the author sees one diagnosis rather
|
|
207
|
+
// than one per alternative.
|
|
208
|
+
function explain(data, errors, problems, depth) {
|
|
209
|
+
const covered = new Set();
|
|
210
|
+
const emit = (where, message) => {
|
|
211
|
+
const key = `${where}${message}`;
|
|
212
|
+
if (!covered.has(key)) {
|
|
213
|
+
covered.add(key);
|
|
214
|
+
problems.push({ level: 'error', where, message });
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
// A union failure explains everything beneath it; the branch is re-validated
|
|
218
|
+
// on its own so the sub-errors reported for the other branches drop out.
|
|
219
|
+
const unions = errors.filter(e => e.keyword === 'anyOf');
|
|
220
|
+
const shadowed = (e) => unions.some(u => u !== e &&
|
|
221
|
+
(e.instancePath === u.instancePath ||
|
|
222
|
+
e.instancePath.startsWith(`${u.instancePath}/`)));
|
|
223
|
+
for (const error of errors) {
|
|
224
|
+
if (shadowed(error) || error.keyword === 'if') {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const where = pathOf(error.instancePath);
|
|
228
|
+
const parent = error.parentSchema;
|
|
229
|
+
switch (error.keyword) {
|
|
230
|
+
case 'additionalProperties':
|
|
231
|
+
case 'unevaluatedProperties': {
|
|
232
|
+
const key = String(error.params.additionalProperty ?? error.params.unevaluatedProperty);
|
|
233
|
+
emit(join(where, key), unknownKeyMessage(key, parent, acceptedKeys(parent)));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'required':
|
|
237
|
+
emit(where, `missing "${String(error.params.missingProperty)}"`);
|
|
238
|
+
break;
|
|
239
|
+
case 'anyOf': {
|
|
240
|
+
const branches = branchesOf(error);
|
|
241
|
+
const value = error.data;
|
|
242
|
+
if (requiredOnly(branches)) {
|
|
243
|
+
emit(where, `needs one of ${branches.map(b => `"${b.required[0]}"`).join(', ')}`);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
const jexlBranch = branches.findIndex(b => refName(b) === 'JexlString');
|
|
247
|
+
if (jexlBranch >= 0 && !isJexl(value)) {
|
|
248
|
+
const others = branches.filter((_, i) => i !== jexlBranch);
|
|
249
|
+
emit(where, `expected ${others.map(describe).join(' or ')} or a "jexl:" expression, got ${JSON.stringify(value)}`);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (depth > 8) {
|
|
253
|
+
emit(where, `does not match any accepted form`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
const pointer = pointers.get(parent);
|
|
257
|
+
const branch = pickBranch(error, value, errors);
|
|
258
|
+
if (pointer === undefined || branch < 0) {
|
|
259
|
+
emit(where, 'does not match any accepted form');
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
const fn = validatorFor(`${pointer}/anyOf/${branch}`);
|
|
263
|
+
if (!fn(value) && fn.errors?.length) {
|
|
264
|
+
const nested = [];
|
|
265
|
+
explain(value, fn.errors, nested, depth + 1);
|
|
266
|
+
for (const p of nested) {
|
|
267
|
+
emit(join(where, p.where).replaceAll('.[', '['), p.message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
emit(where, 'does not match any accepted form');
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
case 'type':
|
|
276
|
+
case 'enum':
|
|
277
|
+
case 'const':
|
|
278
|
+
case 'pattern':
|
|
279
|
+
emit(where, `expected ${describe(parent)}, got ${JSON.stringify(error.data)}`);
|
|
280
|
+
break;
|
|
281
|
+
case 'not':
|
|
282
|
+
emit(where, `must not ${describe(parent.not)}`);
|
|
283
|
+
break;
|
|
284
|
+
default:
|
|
285
|
+
emit(where, error.message ?? error.keyword);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// The problems in `data` against the whole schema, or against one of its
|
|
290
|
+
// definitions by pointer (`/$defs/View` for a session spec's view).
|
|
291
|
+
export function schemaProblems(data, pointer = '') {
|
|
292
|
+
const validate = validatorFor(pointer);
|
|
293
|
+
if (validate(data)) {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
const problems = [];
|
|
297
|
+
explain(data, validate.errors ?? [], problems, 0);
|
|
298
|
+
return problems;
|
|
299
|
+
}
|
|
300
|
+
export { schemaId as configSchemaUrl };
|