@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.
Files changed (3) hide show
  1. package/README.md +38 -3
  2. package/bin/myelin.js +159 -7
  3. 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. It also tells you which checks `check` can
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 a blocking rule fails.
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, locked delivery, blocked submit, locked sample depth).
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
- const gate = c.severity === 'blocking' ? 'must' : 'should'
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
- out(` ${gate === 'must' ? '!' : '·'} ${c.assertion ?? c.name}${when}`)
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 mark = c.verdict === 'passed' ? '✓' : c.verdict === 'flagged' ? '⚠' : '✗'
542
- out(`${mark} ${c.check_type.padEnd(w)} ${c.verdict}${c.severity === 'blocking' && c.verdict === 'failed' ? ' — BLOCKING' : ''}`)
543
- if (c.verdict !== 'passed' && c.remediation) out(` hint: ${c.remediation}`)
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
- out('All checks that run before upload pass.')
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
- out(`Submitting… auto-checks: ${a.passed} passed, ${a.flagged} flagged, ${a.failed} failed.`)
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.9.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": {