@jbrowse/cli 4.3.0 → 5.0.0-beta.1
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 +331 -57
- package/bin/run +1 -1
- package/bundle/index.js +457 -2574
- package/dist/base.js +0 -3
- package/dist/bin.js +2 -1
- package/dist/cliFetch.js +3 -0
- package/dist/commands/add-assembly/index.js +46 -19
- package/dist/commands/add-assembly/utils.js +204 -290
- package/dist/commands/add-connection.js +73 -79
- package/dist/commands/add-track-json.js +24 -12
- package/dist/commands/add-track-utils/adapter-utils.js +173 -288
- package/dist/commands/add-track-utils/file-operations.js +27 -11
- package/dist/commands/add-track-utils/multiwig.js +47 -0
- package/dist/commands/add-track-utils/track-config.js +68 -25
- package/dist/commands/add-track-utils/validators.js +36 -20
- package/dist/commands/add-track.js +178 -62
- package/dist/commands/admin-server/index.js +30 -49
- package/dist/commands/admin-server/utils.js +235 -125
- package/dist/commands/create.js +18 -34
- package/dist/commands/make-pif/cigar-utils.js +62 -19
- package/dist/commands/make-pif/index.js +98 -11
- package/dist/commands/make-pif/pif-generator.js +276 -42
- package/dist/commands/process-utils.js +13 -2
- package/dist/commands/remove-track.js +20 -13
- package/dist/commands/set-default-session.js +35 -31
- package/dist/commands/shared/config-operations.js +3 -10
- package/dist/commands/shared/sort-utils.js +67 -10
- package/dist/commands/shared/validators.js +38 -3
- package/dist/commands/sort-bed.js +2 -33
- package/dist/commands/sort-gff.js +2 -33
- package/dist/commands/text-index/adapter-utils.js +6 -23
- package/dist/commands/text-index/aggregate.js +40 -34
- package/dist/commands/text-index/command.js +35 -7
- package/dist/commands/text-index/config-utils.js +60 -83
- package/dist/commands/text-index/file-list.js +32 -18
- package/dist/commands/text-index/index.js +4 -4
- package/dist/commands/text-index/indexing-utils.js +49 -73
- package/dist/commands/text-index/per-track.js +44 -31
- package/dist/commands/text-index/validators.js +1 -1
- package/dist/commands/upgrade.js +20 -32
- package/dist/commands/validate/configManifest.generated.js +5394 -0
- package/dist/commands/validate/displayDefaultKeys.js +28 -0
- package/dist/commands/validate/index.js +97 -0
- package/dist/commands/validate/suggest.js +35 -0
- package/dist/commands/validate/types.js +6 -0
- package/dist/commands/validate/validateConfig.js +457 -0
- package/dist/index.js +99 -50
- package/dist/types/common.js +3 -27
- package/dist/utils.js +309 -128
- package/dist/version.js +1 -1
- package/package.json +8 -10
- package/dist/commands/make-pif/file-utils.js +0 -30
- package/dist/commands/track-utils.js +0 -74
- package/dist/util.js +0 -35
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { configManifest } from './configManifest.generated.js';
|
|
2
|
+
// `displayDefaults: {color: 'green'}` is a track-level shorthand
|
|
3
|
+
// (expandTrackConfigShorthand.ts) routing each key to whichever of the track's
|
|
4
|
+
// display types declares it, so the keys a track accepts are the union of those
|
|
5
|
+
// displays' slots rather than the track's own.
|
|
6
|
+
export function displayDefaultKeys(trackEntry, manifest) {
|
|
7
|
+
return (trackEntry.displayTypes ?? []).flatMap(name => manifest.displays[name]?.slots.map(slot => slot.name) ?? []);
|
|
8
|
+
}
|
|
9
|
+
// The same set for a track type named rather than resolved — add-track knows
|
|
10
|
+
// the type it is writing, not a manifest entry. The display types come back
|
|
11
|
+
// alongside, because most tracks offer exactly one and naming it is the whole
|
|
12
|
+
// difference between a message someone can act on and one they cannot: an
|
|
13
|
+
// AlignmentsTrack has only LinearAlignmentsDisplay, so "no AlignmentsTrack
|
|
14
|
+
// display declares color" sends the reader looking for the other displays.
|
|
15
|
+
//
|
|
16
|
+
// Both empty for a type the manifest does not carry (a plugin's), which no
|
|
17
|
+
// check here can speak to.
|
|
18
|
+
export function displayDefaultsForTrackType(trackType, manifest = configManifest) {
|
|
19
|
+
const entry = lookup(manifest.tracks, trackType);
|
|
20
|
+
return {
|
|
21
|
+
displayTypes: entry?.displayTypes ?? [],
|
|
22
|
+
keys: entry ? displayDefaultKeys(entry, manifest) : [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function lookup(group, typeName) {
|
|
26
|
+
return (group[typeName] ??
|
|
27
|
+
Object.values(group).find(entry => entry.aliases?.includes(typeName)));
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { printHelp, readConfigFile } from '../../utils.js';
|
|
3
|
+
import { validateConfig } from './validateConfig.js';
|
|
4
|
+
const description = 'Check a JBrowse configuration for errors, including the ones JBrowse itself accepts silently';
|
|
5
|
+
const notes = `A config key JBrowse does not recognize is ignored rather than reported, so a
|
|
6
|
+
misspelled slot leaves the track loading normally with the setting doing
|
|
7
|
+
nothing. That is what this command is mainly for.
|
|
8
|
+
|
|
9
|
+
Two levels are reported:
|
|
10
|
+
|
|
11
|
+
error JBrowse accepts it and silently does the wrong thing — an unknown
|
|
12
|
+
slot, a track naming an assembly the config never defines, a
|
|
13
|
+
defaultSession naming a trackId that does not exist, a duplicate
|
|
14
|
+
trackId. Exits 1.
|
|
15
|
+
|
|
16
|
+
warning JBrowse will complain by itself on load, or handles it — a type name
|
|
17
|
+
the core plugins do not register (expected if one of your plugins
|
|
18
|
+
registers it), or a legacy key a migration rewrites. Never fails
|
|
19
|
+
the run.
|
|
20
|
+
|
|
21
|
+
Types registered by plugins are not known to this command, so they come through
|
|
22
|
+
as warnings rather than errors.`;
|
|
23
|
+
const examples = [
|
|
24
|
+
'# check the config.json in the current directory',
|
|
25
|
+
'$ jbrowse validate',
|
|
26
|
+
'',
|
|
27
|
+
'# check a specific config or saved session',
|
|
28
|
+
'$ jbrowse validate /path/to/config.json',
|
|
29
|
+
'$ jbrowse validate mysession.jbrowse',
|
|
30
|
+
'',
|
|
31
|
+
'# machine-readable output',
|
|
32
|
+
'$ jbrowse validate config.json --json',
|
|
33
|
+
];
|
|
34
|
+
const options = {
|
|
35
|
+
json: {
|
|
36
|
+
type: 'boolean',
|
|
37
|
+
description: 'Output the findings as JSON instead of text',
|
|
38
|
+
},
|
|
39
|
+
quiet: {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
short: 'q',
|
|
42
|
+
description: 'Only print errors, suppressing warnings and notes',
|
|
43
|
+
},
|
|
44
|
+
help: { type: 'boolean', short: 'h', description: 'Show help' },
|
|
45
|
+
};
|
|
46
|
+
function format({ level, where, message }) {
|
|
47
|
+
return where ? `${level}: ${where}: ${message}` : `${level}: ${message}`;
|
|
48
|
+
}
|
|
49
|
+
export async function run(args) {
|
|
50
|
+
const { values: flags, positionals } = parseArgs({
|
|
51
|
+
options,
|
|
52
|
+
args,
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
});
|
|
55
|
+
if (flags.help) {
|
|
56
|
+
printHelp({
|
|
57
|
+
description,
|
|
58
|
+
examples,
|
|
59
|
+
notes,
|
|
60
|
+
usage: 'jbrowse validate [config.json] [options]',
|
|
61
|
+
options,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const file = positionals[0] ?? 'config.json';
|
|
66
|
+
// readConfigFile, not readJsonFile: running from the wrong directory is the
|
|
67
|
+
// most common way this fails, and it says which path it looked at.
|
|
68
|
+
const config = await readConfigFile(file);
|
|
69
|
+
const result = validateConfig(config);
|
|
70
|
+
if (flags.json) {
|
|
71
|
+
console.log(JSON.stringify({ file, ok: result.errorCount === 0, ...result }, null, 2));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const shown = flags.quiet
|
|
75
|
+
? result.problems.filter(p => p.level === 'error')
|
|
76
|
+
: result.problems;
|
|
77
|
+
if (!flags.quiet) {
|
|
78
|
+
for (const note of result.notes) {
|
|
79
|
+
console.log(`note: ${note}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const problem of shown) {
|
|
83
|
+
console.log(format(problem));
|
|
84
|
+
}
|
|
85
|
+
if (result.problems.length === 0) {
|
|
86
|
+
console.log(`${file} looks good`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(`\n${result.errorCount} error(s), ${result.warningCount} warning(s) in ${file}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (result.errorCount > 0) {
|
|
93
|
+
// Non-zero so this can gate a deploy. Thrown rather than process.exit so it
|
|
94
|
+
// goes through the same error path as every other command.
|
|
95
|
+
throw new Error(`${result.errorCount} error(s) found in ${file}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// "did you mean" for a mistyped identifier, shared by the validator and by
|
|
2
|
+
// add-track's up-front check on the settings it is about to write.
|
|
3
|
+
function editDistance(a, b) {
|
|
4
|
+
// Two rolling rows rather than the full matrix; these are short identifiers
|
|
5
|
+
// and this runs once per unknown key.
|
|
6
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
7
|
+
for (let i = 1; i <= a.length; i++) {
|
|
8
|
+
const row = [i];
|
|
9
|
+
for (let j = 1; j <= b.length; j++) {
|
|
10
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
11
|
+
}
|
|
12
|
+
prev = row;
|
|
13
|
+
}
|
|
14
|
+
return prev[b.length];
|
|
15
|
+
}
|
|
16
|
+
// The nearest candidate, if it is near enough to be worth naming. The threshold
|
|
17
|
+
// scales with length so `uri` doesn't match every other three-letter slot while
|
|
18
|
+
// a longer typo still resolves.
|
|
19
|
+
export function suggest(word, candidates) {
|
|
20
|
+
const limit = Math.max(2, Math.floor(word.length / 3));
|
|
21
|
+
let best;
|
|
22
|
+
let bestScore = Infinity;
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
const score = editDistance(word.toLowerCase(), candidate.toLowerCase());
|
|
25
|
+
if (score < bestScore) {
|
|
26
|
+
bestScore = score;
|
|
27
|
+
best = candidate;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return bestScore <= limit ? best : undefined;
|
|
31
|
+
}
|
|
32
|
+
export function didYouMean(word, candidates) {
|
|
33
|
+
const hit = suggest(word, candidates);
|
|
34
|
+
return hit ? ` — did you mean "${hit}"?` : '';
|
|
35
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Shape of the generated config manifest (configManifest.generated.ts), which
|
|
2
|
+
// scripts/generateConfigManifest.ts reads out of the live ConfigurationSchema
|
|
3
|
+
// objects. Declared here rather than inferred from the generated file so the
|
|
4
|
+
// generator and the validator agree on a contract, and a generator change that
|
|
5
|
+
// drops a field is a type error rather than a silent behaviour change.
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// Checks a JBrowse config (or a saved session file) against the generated
|
|
2
|
+
// config-slot manifest.
|
|
3
|
+
//
|
|
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.
|
|
10
|
+
//
|
|
11
|
+
// Pure: no filesystem, no process exit. The command wrapper owns both.
|
|
12
|
+
import { configManifest } from './configManifest.generated.js';
|
|
13
|
+
import { displayDefaultKeys } from './displayDefaultKeys.js';
|
|
14
|
+
import { didYouMean } from './suggest.js';
|
|
15
|
+
// ------------------------------------------------------------------ reporting
|
|
16
|
+
class Report {
|
|
17
|
+
problems = [];
|
|
18
|
+
notes = [];
|
|
19
|
+
error(where, message) {
|
|
20
|
+
this.problems.push({ level: 'error', where, message });
|
|
21
|
+
}
|
|
22
|
+
warn(where, message) {
|
|
23
|
+
this.problems.push({ level: 'warning', where, message });
|
|
24
|
+
}
|
|
25
|
+
note(message) {
|
|
26
|
+
this.notes.push(message);
|
|
27
|
+
}
|
|
28
|
+
result() {
|
|
29
|
+
return {
|
|
30
|
+
problems: this.problems,
|
|
31
|
+
notes: this.notes,
|
|
32
|
+
errorCount: this.problems.filter(p => p.level === 'error').length,
|
|
33
|
+
warningCount: this.problems.filter(p => p.level === 'warning').length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
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).
|
|
63
|
+
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
|
+
},
|
|
68
|
+
};
|
|
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;
|
|
79
|
+
}
|
|
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;
|
|
86
|
+
}
|
|
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)) {
|
|
91
|
+
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
|
+
}
|
|
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
|
+
}
|
|
97
|
+
// Recurse into sub-schemas the config actually filled in. One it omits is
|
|
98
|
+
// fine: the sub-schema supplies its own defaults.
|
|
99
|
+
for (const slot of entry.slots) {
|
|
100
|
+
const value = obj[slot.name];
|
|
101
|
+
if (slot.subSlots && isRecord(value)) {
|
|
102
|
+
checkSlots(value, { slots: slot.subSlots }, `${where}.${slot.name}`, report);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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.
|
|
118
|
+
function resolveType(obj, group, groupLabel, where, report) {
|
|
119
|
+
const typeName = obj.type;
|
|
120
|
+
if (typeof typeName !== 'string') {
|
|
121
|
+
report.error(where, `missing "type" — expected one of the ${groupLabel} types`);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const canonical = typeName in group
|
|
125
|
+
? 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));
|
|
131
|
+
const entry = canonical === undefined ? undefined : group[canonical];
|
|
132
|
+
if (entry && canonical !== undefined) {
|
|
133
|
+
return { ...entry, canonicalName: canonical };
|
|
134
|
+
}
|
|
135
|
+
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
|
+
return undefined;
|
|
137
|
+
}
|
|
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
|
+
function checkPluggable(obj, group, groupLabel, where, report) {
|
|
154
|
+
if (!isRecord(obj)) {
|
|
155
|
+
report.error(where, `${groupLabel} must be an object`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const entry = resolveType(obj, group, groupLabel, `${where}.type`, report);
|
|
159
|
+
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
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function checkTrack(track, index, manifest, report, ctx) {
|
|
179
|
+
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);
|
|
188
|
+
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);
|
|
197
|
+
}
|
|
198
|
+
const names = track.assemblyNames;
|
|
199
|
+
if (!Array.isArray(names) || names.length === 0) {
|
|
200
|
+
if (!loose || ctx.assemblyCount !== 1) {
|
|
201
|
+
report.error(`${where}.assemblyNames`, 'missing "assemblyNames"');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
for (const name of names) {
|
|
206
|
+
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.
|
|
211
|
+
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
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
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);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!loose) {
|
|
231
|
+
checkAdapter(track.adapter, manifest, `${where}.adapter`, report);
|
|
232
|
+
}
|
|
233
|
+
const textSearching = track.textSearching;
|
|
234
|
+
if (isRecord(textSearching) &&
|
|
235
|
+
textSearching.textSearchAdapter !== undefined) {
|
|
236
|
+
checkPluggable(textSearching.textSearchAdapter, manifest.textSearchAdapters, 'text search adapter', `${where}.textSearching.textSearchAdapter`, report);
|
|
237
|
+
}
|
|
238
|
+
if (Array.isArray(track.displays)) {
|
|
239
|
+
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
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
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;
|
|
282
|
+
}
|
|
283
|
+
checkAdapter(sequence.adapter, manifest, `${where}.sequence.adapter`, report);
|
|
284
|
+
}
|
|
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.
|
|
295
|
+
function checkSessionDisplay(display, manifest, where, report) {
|
|
296
|
+
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
|
+
if (!entry?.stateModelProps) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const props = entry.stateModelProps;
|
|
303
|
+
const slots = entry.slots.map(slot => slot.name);
|
|
304
|
+
const migrated = new Set([
|
|
305
|
+
...(manifest.migratedDisplayKeys['*'] ?? []),
|
|
306
|
+
...(manifest.migratedDisplayKeys[entry.canonicalName] ?? []),
|
|
307
|
+
]);
|
|
308
|
+
for (const key of Object.keys(display)) {
|
|
309
|
+
if (props.includes(key)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (migrated.has(key)) {
|
|
313
|
+
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
|
+
}
|
|
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
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Walks the tracks of a view and its sub-views (a synteny view holds a row of
|
|
324
|
+
// LGVs, each with tracks of its own).
|
|
325
|
+
function checkSessionViewTracks(view, manifest, where, report) {
|
|
326
|
+
if (!isRecord(view)) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (Array.isArray(view.tracks)) {
|
|
330
|
+
for (const [i, track] of view.tracks.entries()) {
|
|
331
|
+
if (!isRecord(track) || !Array.isArray(track.displays)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
for (const [j, display] of track.displays.entries()) {
|
|
335
|
+
if (isRecord(display)) {
|
|
336
|
+
checkSessionDisplay(display, manifest, `${where}.tracks[${i}].displays[${j}]`, report);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (Array.isArray(view.views)) {
|
|
342
|
+
for (const [i, sub] of view.views.entries()) {
|
|
343
|
+
checkSessionViewTracks(sub, manifest, `${where}.views[${i}]`, report);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
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.
|
|
350
|
+
function checkSession(session, manifest, report, ctx) {
|
|
351
|
+
if (!isRecord(session) || !Array.isArray(session.views)) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
for (const [i, view] of session.views.entries()) {
|
|
355
|
+
checkSessionViewTracks(view, manifest, `defaultSession.views[${i}]`, report);
|
|
356
|
+
}
|
|
357
|
+
for (const [i, view] of session.views.entries()) {
|
|
358
|
+
if (!isRecord(view) || !isRecord(view.init)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
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])}`);
|
|
366
|
+
}
|
|
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
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
export function validateConfig(config, manifest = configManifest) {
|
|
391
|
+
const report = new Report();
|
|
392
|
+
if (!isRecord(config)) {
|
|
393
|
+
report.error('', 'config is not a JSON object');
|
|
394
|
+
return report.result();
|
|
395
|
+
}
|
|
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`);
|
|
398
|
+
}
|
|
399
|
+
const assemblies = (Array.isArray(config.assemblies)
|
|
400
|
+
? config.assemblies
|
|
401
|
+
: isRecord(config.assembly)
|
|
402
|
+
? [config.assembly]
|
|
403
|
+
: []).filter(isRecord);
|
|
404
|
+
if (assemblies.length === 0) {
|
|
405
|
+
report.error('assemblies', 'no assemblies — a config needs at least one');
|
|
406
|
+
}
|
|
407
|
+
const connections = Array.isArray(config.connections)
|
|
408
|
+
? config.connections
|
|
409
|
+
: [];
|
|
410
|
+
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.
|
|
414
|
+
assemblyNames: new Set(assemblies
|
|
415
|
+
.flatMap(a => [a.name, ...(Array.isArray(a.aliases) ? a.aliases : [])])
|
|
416
|
+
.filter((n) => typeof n === 'string')),
|
|
417
|
+
assemblyCount: assemblies.length,
|
|
418
|
+
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.
|
|
425
|
+
sequenceTrackIds: new Set(assemblies.flatMap(a => {
|
|
426
|
+
const declared = isRecord(a.sequence) ? a.sequence.trackId : undefined;
|
|
427
|
+
return [
|
|
428
|
+
...(typeof declared === 'string' ? [declared] : []),
|
|
429
|
+
...(typeof a.name === 'string'
|
|
430
|
+
? [`${a.name}-ReferenceSequenceTrack`]
|
|
431
|
+
: []),
|
|
432
|
+
];
|
|
433
|
+
})),
|
|
434
|
+
};
|
|
435
|
+
for (const [i, assembly] of assemblies.entries()) {
|
|
436
|
+
checkAssembly(assembly, i, manifest, report);
|
|
437
|
+
}
|
|
438
|
+
const tracks = Array.isArray(config.tracks)
|
|
439
|
+
? config.tracks.filter(isRecord)
|
|
440
|
+
: [];
|
|
441
|
+
for (const [i, track] of tracks.entries()) {
|
|
442
|
+
checkTrack(track, i, manifest, report, ctx);
|
|
443
|
+
}
|
|
444
|
+
for (const [i, connection] of connections.entries()) {
|
|
445
|
+
checkPluggable(connection, manifest.connections, 'connection', `connections[${i}]`, report);
|
|
446
|
+
}
|
|
447
|
+
const aggregate = Array.isArray(config.aggregateTextSearchAdapters)
|
|
448
|
+
? config.aggregateTextSearchAdapters
|
|
449
|
+
: [];
|
|
450
|
+
for (const [i, adapter] of aggregate.entries()) {
|
|
451
|
+
checkPluggable(adapter, manifest.textSearchAdapters, 'text search adapter', `aggregateTextSearchAdapters[${i}]`, report);
|
|
452
|
+
}
|
|
453
|
+
// Tracks are registered before the session is checked, so a session may
|
|
454
|
+
// reference any track in the file regardless of declaration order.
|
|
455
|
+
checkSession(config.defaultSession, manifest, report, ctx);
|
|
456
|
+
return report.result();
|
|
457
|
+
}
|