@myelinbridge/cli 0.9.0 → 0.10.0
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 +38 -3
- package/bin/myelin.js +159 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,17 +50,36 @@ npx @myelinbridge/cli push ./run_042 --dataset onco1-wes --submit
|
|
|
50
50
|
- **Read the contract before you build the delivery.** `contract` prints what the
|
|
51
51
|
client expects — every check as one plain sentence, grouped by what it answers
|
|
52
52
|
(completeness, structure, validity, consistency, integrity, privacy), and marked
|
|
53
|
-
`!` when a failure blocks validation
|
|
53
|
+
`!` when a failure blocks validation or `?` when the client's reviewer confirms
|
|
54
|
+
it rather than asking you to fix it. It also tells you which checks `check` can
|
|
54
55
|
verify locally and which only run once the files are uploaded, so nothing about
|
|
55
56
|
the bar is a surprise at review time.
|
|
56
57
|
|
|
57
58
|
- **`check` costs nothing.** It evaluates your local file list against the dataset's
|
|
58
59
|
quality checks server-side — same engine, same verdicts as submit — without
|
|
59
|
-
uploading. Exit code 2 means
|
|
60
|
+
uploading. Exit code 2 means the delivery would not be validatable as it stands:
|
|
61
|
+
a blocking rule fails, **or** a blocking rule could not be evaluated at all.
|
|
62
|
+
- **A check has five answers, not three** (since 0.10.0). Alongside *passed*,
|
|
63
|
+
*flagged* and *failed*, the engine can now say it **could not check** something
|
|
64
|
+
(a missing precondition, or a rule it cannot evaluate) and that a rule **does
|
|
65
|
+
not apply** to your delivery. This matters to you in one specific way: a rule
|
|
66
|
+
the client wrote badly used to come back as a *failure on your delivery*. It
|
|
67
|
+
now says the rule could not be evaluated, and tells you it is not yours to fix.
|
|
68
|
+
`check` prints the count it could not check and what each one needs — supply it
|
|
69
|
+
and those checks run on your next delivery, which is fewer rejections later.
|
|
70
|
+
- **Some checks are confirmed, not fixed.** A rule marked *needs confirmation*
|
|
71
|
+
asks nothing of you: the client's reviewer records what they decided and the
|
|
72
|
+
delivery proceeds. `check` reports these and never exits 2 on them.
|
|
60
73
|
- **The fix loop is machine-readable.** On `changes_requested`,
|
|
61
74
|
`myelin status <batch> --json` returns the failed files, reviewer comments, and
|
|
62
75
|
rule remediation hints; fix, re-`push --submit`, unchanged files keep their
|
|
63
76
|
review votes.
|
|
77
|
+
- **Limits.** `push` declares 500 files per call and chunks automatically — nothing
|
|
78
|
+
for you to split. `check` sends your whole file list in one preflight
|
|
79
|
+
call, which accepts at most 10 000 files — a larger delivery cannot be preflighted
|
|
80
|
+
by `check` today (the API answers `400 too_many_files`); its checks still run at
|
|
81
|
+
submit. A delivery holds at most 25 000 files (submit refuses above that with
|
|
82
|
+
`422 submit_blocked`), and a checksum manifest is inspected up to 16 MiB.
|
|
64
83
|
|
|
65
84
|
## If you are the client, not the partner
|
|
66
85
|
|
|
@@ -104,7 +123,23 @@ A partner key calling these gets `403 wrong_key_side`, and vice versa.
|
|
|
104
123
|
## Machine mode
|
|
105
124
|
|
|
106
125
|
Every command takes `--json`. Exit codes: `0` ok · `1` error · `2` blocked
|
|
107
|
-
(blocking preflight failure,
|
|
126
|
+
(blocking preflight failure, **a blocking rule the engine could not evaluate**,
|
|
127
|
+
locked delivery, blocked submit, locked sample depth).
|
|
128
|
+
|
|
129
|
+
`check --json` carries the numbers to branch on rather than the prose:
|
|
130
|
+
|
|
131
|
+
| Field | |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `blocking_failures` | Blocking rules that were evaluated and failed. Unchanged meaning since the first release. |
|
|
134
|
+
| `blocking_not_evaluated` | Blocking rules the engine could not evaluate. **New in 0.10.0** — these stop the reviewer validating just as surely, which is why `check` now exits `2` on them too. |
|
|
135
|
+
| `must_acknowledge_failures` | Rules the client asked to be told about. Reported, never exited on. |
|
|
136
|
+
| `counts` | `passed`, `flagged`, `failed`, `not_evaluated`, `not_applicable` (which sum to `evaluated`), plus `deferred`, `manual` and `checks` (`evaluated + deferred + manual === checks`). |
|
|
137
|
+
|
|
138
|
+
Every per-check result carries `details.abstained` when the engine did not
|
|
139
|
+
conclude: `rule` means **the client's rule** could not be evaluated and there is
|
|
140
|
+
nothing on your side to fix; `data` means something the check needs is missing
|
|
141
|
+
from the delivery; `nothing_to_evaluate` means the rule matched none of your
|
|
142
|
+
files. Branch on that before you page anyone.
|
|
108
143
|
|
|
109
144
|
API errors carry structure, not just prose: in `--json` mode an error is
|
|
110
145
|
`{ "error", "code", "status", "request_id" }` — branch on `code`, and quote the
|
package/bin/myelin.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Exit codes: 0 ok · 1 error · 2 blocked (failing checks / locked delivery).
|
|
9
9
|
// Every command accepts --json for machine-readable output.
|
|
10
|
+
//
|
|
11
|
+
// 0.10.0 — the engine learned to abstain, so a check has five answers now, not
|
|
12
|
+
// three (`not_evaluated` = "we could not look", `not_applicable` = "this does
|
|
13
|
+
// not apply here"). Two consequences live in this file. Nothing renders an
|
|
14
|
+
// unknown verdict as ✗ any more: 0.9.1 did, so a check nobody ran printed as a
|
|
15
|
+
// failure the partner had caused. And `check` exits 2 on a BLOCKING rule the
|
|
16
|
+
// engine could not evaluate as well as on one it failed — the client's reviewer
|
|
17
|
+
// cannot validate either without deciding by hand, and hearing "all checks
|
|
18
|
+
// pass" before uploading is the green-before-sending / red-after trap this
|
|
19
|
+
// release exists to close.
|
|
10
20
|
|
|
11
21
|
import { readdirSync, statSync, createReadStream, readFileSync } from 'node:fs'
|
|
12
22
|
import { resolve, join, relative, sep, basename } from 'node:path'
|
|
@@ -63,6 +73,35 @@ const die = (message, code = 1, extra = null) => {
|
|
|
63
73
|
|
|
64
74
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
65
75
|
|
|
76
|
+
// ── The verdict vocabulary, as a partner reads it ──────────────────────────
|
|
77
|
+
//
|
|
78
|
+
// ⚠ NEVER print the raw token. `not_evaluated` on a terminal reads as an
|
|
79
|
+
// internal enum and, worse, as an accusation: a partner seeing it next to their
|
|
80
|
+
// delivery assumes their delivery is what failed. It is not — in the `rule`
|
|
81
|
+
// case it is the client's rule that could not be evaluated.
|
|
82
|
+
//
|
|
83
|
+
// ⚠ And never `✗` for a verdict we do not recognise. That is exactly what 0.9.1
|
|
84
|
+
// does (`verdict === 'passed' ? '✓' : verdict === 'flagged' ? '⚠' : '✗'`), so
|
|
85
|
+
// every check that abstains prints to an installed old client as a failure. A
|
|
86
|
+
// value from a newer server than this binary falls through to `?` and its own
|
|
87
|
+
// raw name — visibly unknown, never falsely red.
|
|
88
|
+
const VERDICT_MARK = {
|
|
89
|
+
passed: '✓',
|
|
90
|
+
flagged: '⚠',
|
|
91
|
+
failed: '✗',
|
|
92
|
+
not_evaluated: '?',
|
|
93
|
+
not_applicable: '–',
|
|
94
|
+
}
|
|
95
|
+
const VERDICT_LABEL = {
|
|
96
|
+
passed: 'passed',
|
|
97
|
+
flagged: 'flagged',
|
|
98
|
+
failed: 'failed',
|
|
99
|
+
not_evaluated: 'not checked',
|
|
100
|
+
not_applicable: 'does not apply here',
|
|
101
|
+
}
|
|
102
|
+
const verdictMark = (v) => VERDICT_MARK[v] ?? '?'
|
|
103
|
+
const verdictLabel = (v) => VERDICT_LABEL[v] ?? v
|
|
104
|
+
|
|
66
105
|
// Column widths come from the content. padEnd() alone silently ran a long
|
|
67
106
|
// value into the next column — a 28-character dataset slug swallowed the
|
|
68
107
|
// STATUS header's gutter. Pass headers = null for an unheadered list.
|
|
@@ -479,6 +518,9 @@ async function cmdContract() {
|
|
|
479
518
|
out(`${dataset.name} — delivery contract v${j.version}`)
|
|
480
519
|
out(
|
|
481
520
|
`${s.total} checks · ${s.blocking} block validation · ` +
|
|
521
|
+
// Only when there are any: a contract with no rule at this rung must read
|
|
522
|
+
// exactly as it did before 0.10.0, not gain a "0 confirmed".
|
|
523
|
+
(s.must_acknowledge ? `${s.must_acknowledge} confirmed by your reviewer · ` : '') +
|
|
482
524
|
`${s.checkable_before_upload} checkable before upload` +
|
|
483
525
|
(s.needs_reviewer ? ` · ${s.needs_reviewer} reviewed by a person` : ''),
|
|
484
526
|
)
|
|
@@ -496,9 +538,18 @@ async function cmdContract() {
|
|
|
496
538
|
if (items.length === 0) continue
|
|
497
539
|
out(`${d.label.toUpperCase()} — ${d.question}`)
|
|
498
540
|
for (const c of items) {
|
|
499
|
-
|
|
541
|
+
// THREE rungs, not two. The ternary this replaces called everything that
|
|
542
|
+
// was not `blocking` a "should", so a `must_acknowledge` rule — the one
|
|
543
|
+
// that demands a named decision from the reviewer — printed identically
|
|
544
|
+
// to a warning nobody has to read.
|
|
545
|
+
const mark =
|
|
546
|
+
c.severity === 'blocking' ? '!' : c.severity === 'must_acknowledge' ? '?' : '·'
|
|
500
547
|
const when = c.runs_at === 'preflight' ? '' : ' (checked at submission)'
|
|
501
|
-
|
|
548
|
+
const note =
|
|
549
|
+
c.severity === 'must_acknowledge'
|
|
550
|
+
? ' — your client’s reviewer records a decision on this; nothing for you to fix'
|
|
551
|
+
: ''
|
|
552
|
+
out(` ${mark} ${c.assertion ?? c.name}${when}${note}`)
|
|
502
553
|
}
|
|
503
554
|
out('')
|
|
504
555
|
}
|
|
@@ -538,19 +589,92 @@ async function cmdCheck() {
|
|
|
538
589
|
...j.manual.map((m) => m.name.length),
|
|
539
590
|
)
|
|
540
591
|
for (const c of j.evaluated) {
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
592
|
+
const gate =
|
|
593
|
+
c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING'
|
|
594
|
+
: c.severity === 'blocking' && c.verdict === 'not_evaluated' ? ' — BLOCKING, and nobody looked'
|
|
595
|
+
: c.severity === 'must_acknowledge' && c.verdict === 'failed' ? ' — your reviewer confirms this'
|
|
596
|
+
: ''
|
|
597
|
+
out(`${verdictMark(c.verdict)} ${c.check_type.padEnd(w)} ${verdictLabel(c.verdict)}${gate}`)
|
|
598
|
+
// Why we could not look, in the partner's terms. `abstained: 'rule'` is
|
|
599
|
+
// the one that matters most: it says the fault is in the client's rule,
|
|
600
|
+
// not in this delivery, and without the sentence the partner spends a
|
|
601
|
+
// morning re-checking files that were never the problem.
|
|
602
|
+
if (c.verdict === 'not_evaluated') {
|
|
603
|
+
out(
|
|
604
|
+
c.details?.abstained === 'rule'
|
|
605
|
+
? ' this rule could not be evaluated — nothing to fix on your side; ask your client to check it'
|
|
606
|
+
: ' the delivery is missing something this check needs — see the hint below, or your client’s contract',
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
// A remediation hint answers "how do I fix this?", which only has an
|
|
610
|
+
// answer when there IS something on the partner's side to fix: a verdict
|
|
611
|
+
// the engine reached, or a missing precondition they can supply. Never
|
|
612
|
+
// under a `rule` abstention (the client's rule is what broke) and never
|
|
613
|
+
// under `not_applicable` (there is nothing to do) — printing one there is
|
|
614
|
+
// an instruction to fix nothing.
|
|
615
|
+
const fixable =
|
|
616
|
+
c.verdict === 'failed' ||
|
|
617
|
+
c.verdict === 'flagged' ||
|
|
618
|
+
(c.verdict === 'not_evaluated' && c.details?.abstained !== 'rule')
|
|
619
|
+
if (fixable && c.remediation) out(` hint: ${c.remediation}`)
|
|
544
620
|
}
|
|
545
621
|
for (const d of j.deferred) out(`… ${d.check_type.padEnd(w)} ${d.reason}`)
|
|
546
622
|
for (const m of j.manual) out(`○ ${m.name.padEnd(w)} ${m.reason}`)
|
|
547
623
|
}
|
|
624
|
+
|
|
625
|
+
// ── The verdict of the run ────────────────────────────────────────────────
|
|
626
|
+
//
|
|
627
|
+
// Two things block the client's reviewer from validating, not one, and this
|
|
628
|
+
// command has to report both or it is telling a partner their delivery is
|
|
629
|
+
// clear when it is not (lib/quality/readiness.ts is the gate; the API's
|
|
630
|
+
// `blocking_not_evaluated` is the same number computed the same way).
|
|
631
|
+
//
|
|
632
|
+
// `must_acknowledge` deliberately does NOT exit 2. It is the rung that asks
|
|
633
|
+
// the reviewer for a decision and the partner for nothing, and a pipeline
|
|
634
|
+
// halting on it would put back exactly the over-blocking it was invented to
|
|
635
|
+
// remove — "to the CRO we block nothing" is the point of it.
|
|
636
|
+
const blockingAbstained = (j.evaluated ?? []).filter(
|
|
637
|
+
(c) => c.severity === 'blocking' && c.verdict === 'not_evaluated',
|
|
638
|
+
)
|
|
548
639
|
if (j.blocking_failures > 0) {
|
|
549
640
|
out(`${j.blocking_failures} blocking issue(s). Fix before pushing to avoid a review round-trip.`)
|
|
641
|
+
}
|
|
642
|
+
if (blockingAbstained.length > 0) {
|
|
643
|
+
out(
|
|
644
|
+
`${blockingAbstained.length} blocking check(s) could not be evaluated — nobody looked, so your ` +
|
|
645
|
+
`client's reviewer has to decide on each one by hand before this delivery can be validated.`,
|
|
646
|
+
)
|
|
647
|
+
const rule = blockingAbstained.filter((c) => c.details?.abstained === 'rule')
|
|
648
|
+
if (rule.length > 0) {
|
|
649
|
+
out(` ${rule.length} of them because the RULE could not be evaluated, not your delivery: ${rule.map((c) => c.check_type).join(', ')}`)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (j.blocking_failures + blockingAbstained.length > 0) {
|
|
550
653
|
process.exitCode = 2
|
|
551
654
|
return
|
|
552
655
|
}
|
|
553
|
-
|
|
656
|
+
if (j.must_acknowledge_failures > 0) {
|
|
657
|
+
out(`${j.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix; their reviewer records a decision. Not a blocker.`)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const k = j.counts
|
|
661
|
+
// "All checks that run before upload pass" was written when every check
|
|
662
|
+
// reached a conclusion. It is a green claim over rules nobody ran the moment
|
|
663
|
+
// one abstains, so it is now reserved for the run where that is literally
|
|
664
|
+
// true, and the coverage is stated the rest of the time — framed as a
|
|
665
|
+
// capability to gain, because it is one: more checks running before you send
|
|
666
|
+
// means fewer rejections after.
|
|
667
|
+
if (!k || k.passed + k.not_applicable === k.evaluated) {
|
|
668
|
+
out('All checks that run before upload pass.')
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
const rest = []
|
|
672
|
+
if (k.flagged + k.failed > 0) rest.push(`${k.flagged + k.failed} worth a look`)
|
|
673
|
+
if (k.not_evaluated > 0) rest.push(`${k.not_evaluated} we could not check`)
|
|
674
|
+
out(`Nothing blocking. ${k.passed}/${k.evaluated} pass${rest.length ? ` — ${rest.join(', ')}` : ''}.`)
|
|
675
|
+
if (k.not_evaluated > 0) {
|
|
676
|
+
out(`Supply what ${k.not_evaluated === 1 ? 'it needs' : 'they need'} and ${k.not_evaluated === 1 ? 'it runs' : 'they run'} on your next delivery.`)
|
|
677
|
+
}
|
|
554
678
|
}
|
|
555
679
|
|
|
556
680
|
async function cmdPush() {
|
|
@@ -649,8 +773,22 @@ async function cmdPush() {
|
|
|
649
773
|
// success instead of failing, and so do we — nothing to redo.
|
|
650
774
|
out(`✓ Batch already submitted (${j.status}) — nothing to redo. Track: myelin status ${batchId} --watch`)
|
|
651
775
|
} else {
|
|
776
|
+
// ⚠ This line used to print three numbers and no denominator, which was
|
|
777
|
+
// exhaustive until the engine could abstain — after that, an abstention
|
|
778
|
+
// was in none of the three and "7 passed, 1 flagged, 0 failed" on a
|
|
779
|
+
// ten-rule contract looked complete while two rules had never been
|
|
780
|
+
// looked at. `of N` is the denominator that makes the omission visible.
|
|
652
781
|
const a = j.auto_checks
|
|
653
|
-
|
|
782
|
+
const tally = [`${a.passed} passed`, `${a.flagged} flagged`, `${a.failed} failed`]
|
|
783
|
+
if (a.not_evaluated) tally.push(`${a.not_evaluated} not checked`)
|
|
784
|
+
if (a.not_applicable) tally.push(`${a.not_applicable} not applicable`)
|
|
785
|
+
out(`Submitting… auto-checks: ${tally.join(', ')}${a.total ? ` of ${a.total}` : ''}.`)
|
|
786
|
+
if (a.blocking_not_evaluated) {
|
|
787
|
+
out(` ${a.blocking_not_evaluated} blocking check(s) could not be evaluated — your client's reviewer decides on those by hand.`)
|
|
788
|
+
}
|
|
789
|
+
if (a.must_acknowledge_failures) {
|
|
790
|
+
out(` ${a.must_acknowledge_failures} check(s) your client asked to be told about — nothing to fix.`)
|
|
791
|
+
}
|
|
654
792
|
out(`✓ Batch submitted for review. Track: myelin status ${batchId} --watch`)
|
|
655
793
|
}
|
|
656
794
|
} else {
|
|
@@ -687,6 +825,20 @@ async function cmdStatus() {
|
|
|
687
825
|
for (const qf of f.quality_findings.filter((x) => x.verdict === 'failed')) {
|
|
688
826
|
out(` ✗ ${qf.name ?? qf.rule_key}${qf.hint ? `: ${qf.hint}` : ''}`)
|
|
689
827
|
}
|
|
828
|
+
// ⚠ The filter above is "what to fix", and until 0.10.0 it was the whole
|
|
829
|
+
// list. A rule the engine could not evaluate is not a failure and never
|
|
830
|
+
// matched it — so a delivery bounced back partly BECAUSE two checks could
|
|
831
|
+
// not run showed the partner only the failures, and they resubmitted into
|
|
832
|
+
// the same wall. An abstention that a partner can clear (`abstained`
|
|
833
|
+
// anything but 'rule') belongs on this list; a `rule` one belongs to the
|
|
834
|
+
// client and is printed as such rather than as work owed.
|
|
835
|
+
for (const qf of f.quality_findings.filter((x) => x.verdict === 'not_evaluated')) {
|
|
836
|
+
out(
|
|
837
|
+
qf.details?.abstained === 'rule'
|
|
838
|
+
? ` ? ${qf.name ?? qf.rule_key}: this rule could not be evaluated — ask your client to check it`
|
|
839
|
+
: ` ? ${qf.name ?? qf.rule_key}: we could not check this${qf.hint ? ` — ${qf.hint}` : ''}`,
|
|
840
|
+
)
|
|
841
|
+
}
|
|
690
842
|
}
|
|
691
843
|
return batch
|
|
692
844
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myelinbridge/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Myelin Partner Ingestion CLI — push R&D data deliveries from a pipeline: preflight against the client's quality rules, resumable upload, submit, track review outcomes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|