dsh-molbio-tools 0.22.2 → 0.24.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 (44) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +51 -19
  3. package/benchmark/README.md +7 -7
  4. package/benchmark/fixtures.mjs +32 -4
  5. package/benchmark/run.mjs +1 -0
  6. package/benchmark/sequences.mjs +1 -0
  7. package/benchmark/tasks/02-primers.json +3 -3
  8. package/benchmark/tasks/06-analysis.json +111 -1
  9. package/benchmark/tasks/09-formats.json +44 -1
  10. package/benchmark/verifications.mjs +13 -0
  11. package/build/client-entry.mjs +80 -1
  12. package/build/panel-core.mjs +85 -0
  13. package/build/release-preflight.mjs +1 -1
  14. package/contamination.mjs +229 -0
  15. package/cordis.patch.yml +9 -5
  16. package/design.mjs +111 -78
  17. package/docs/capability-gap-survey.md +5 -0
  18. package/docs/client-panel.md +9 -7
  19. package/docs/history.md +62 -0
  20. package/docs/maintainer.md +8 -5
  21. package/docs/roadmap.md +33 -15
  22. package/docs/rules.md +5 -2
  23. package/docs/store-evidence.md +30 -23
  24. package/docs/store-preset-exemption-issue.md +1 -1
  25. package/docs/store-preset-exemption.md +1 -1
  26. package/docs/v24-plan.md +178 -0
  27. package/docs/workflow.md +11 -10
  28. package/dotplot.mjs +257 -0
  29. package/fastq-qc.mjs +6 -9
  30. package/fastq-trim.mjs +238 -0
  31. package/index.mjs +470 -24
  32. package/lib/client.js +327 -93
  33. package/package.json +8 -3
  34. package/preset/molbio-lab/agent.cordis.yml +19 -13
  35. package/preset/molbio-lab/preset.yml +1 -1
  36. package/protein-charge.mjs +158 -0
  37. package/protein.mjs +36 -14
  38. package/seqio.mjs +10 -0
  39. package/test/client.mjs +12 -5
  40. package/test/contract.mjs +44 -12
  41. package/test/drift-probe.mjs +15 -1
  42. package/test/map-card.mjs +139 -1
  43. package/test/preset-health.mjs +39 -0
  44. package/test/smoke.mjs +288 -11
package/test/map-card.mjs CHANGED
@@ -344,6 +344,144 @@ assert.ok(unparseableText.includes('could not be parsed as SVG'), 'unparseable m
344
344
  assert.ok(unparseableText.includes('pCARD · 206 bp'), 'and the header is still there, above the explanation');
345
345
  unparseable.unmount();
346
346
 
347
+ // ── figure cards: sequence logo and gRNA design ─────────────────────────────
348
+
349
+ /** Deterministic pseudo-random DNA for the gRNA fixture. */
350
+ function dnaFixture(n, seed) {
351
+ let s = seed;
352
+ const bases = 'ACGT';
353
+ let out = '';
354
+ for (let i = 0; i < n; i++) {
355
+ s = (s * 1103515245 + 12345) % 2147483648;
356
+ out += bases[(s >> 16) % 4];
357
+ }
358
+ return out;
359
+ }
360
+
361
+ const logoTool = registered.find((entry) => entry.name === 'molbio_sequence_logo');
362
+ const grnaTool = registered.find((entry) => entry.name === 'molbio_grna_design');
363
+ assert.ok(logoTool !== undefined && grnaTool !== undefined, 'both figure tools register');
364
+
365
+ const logoArgs = { sequences: ['ACGTACGTACGTACGT', 'ACGTACGTACGTACGA', 'ACGTACGTTCGTACGT'] };
366
+ const logoValue = await logoTool.execute(logoArgs, exec);
367
+ const logoMeta = logoTool.output.presentationMeta(logoArgs, logoValue);
368
+ assert.equal(logoMeta.kind, 'molbio-figure');
369
+ assert.equal(logoMeta.figure, 'logo');
370
+ assert.equal(logoMeta.svg_path, logoValue.svg_path);
371
+ assert.ok(typeof logoMeta.svg === 'string' && logoMeta.svg.startsWith('<svg'), 'the logo card meta carries the markup');
372
+ assert.equal(logoMeta.svg_bytes, logoMeta.svg.length);
373
+ assert.match(logoMeta.summary, /3 sequences × 16 columns/, 'the summary states the alignment size');
374
+
375
+ const logoView = panelCore.figureCardView(logoMeta);
376
+ assert.equal(logoView.kind, 'figure', 'the card resolves the logo projection');
377
+ assert.equal(logoView.svg, logoMeta.svg);
378
+ assert.equal(logoView.svgPath, logoValue.svg_path);
379
+ assert.equal(panelCore.figureCardSummary(logoView), logoMeta.summary);
380
+
381
+ const grnaArgs = {
382
+ sequence: dnaFixture(320, 7),
383
+ map_path: at('grna-map.svg'),
384
+ save_path: at('grna-order.csv'),
385
+ max_guides: 5,
386
+ };
387
+ const grnaValue = await grnaTool.execute(grnaArgs, exec);
388
+ assert.ok(grnaValue.guides.length > 0, 'the gRNA fixture yields at least one guide');
389
+ const grnaMeta = grnaTool.output.presentationMeta(grnaArgs, grnaValue);
390
+ assert.equal(grnaMeta.kind, 'molbio-figure');
391
+ assert.equal(grnaMeta.figure, 'grna');
392
+ assert.equal(grnaMeta.svg_path, grnaValue.map_path);
393
+ assert.ok(typeof grnaMeta.svg === 'string' && grnaMeta.svg.startsWith('<svg'), 'the gRNA card carries the map markup');
394
+ assert.ok(grnaMeta.guides.length >= 1 && grnaMeta.guides.length <= 5, 'the card carries at most five guide rows');
395
+ assert.equal(grnaMeta.guides[0].sequence, grnaValue.guides[0].sequence);
396
+ assert.equal(grnaMeta.csv_path, grnaValue.saved_to);
397
+ assert.match(grnaMeta.summary, /SpCas9/, 'the summary names the Cas family');
398
+
399
+ const grnaView = panelCore.figureCardView(grnaMeta);
400
+ assert.equal(grnaView.kind, 'figure');
401
+ assert.equal(grnaView.guides.length, grnaMeta.guides.length);
402
+ assert.equal(grnaView.guides[0].offTargets, grnaValue.guides[0].off_target_count ?? 0);
403
+
404
+ // A gRNA call WITHOUT a map still gets the ranked-guide table, with an
405
+ // explanation for the absent picture rather than a blank card.
406
+ const plainGrnaValue = await grnaTool.execute({ sequence: dnaFixture(320, 7), max_guides: 5 }, exec);
407
+ const plainMeta = grnaTool.output.presentationMeta({ sequence: dnaFixture(320, 7) }, plainGrnaValue);
408
+ assert.equal(plainMeta.svg_missing, true);
409
+ const plainView = panelCore.figureCardView(plainMeta);
410
+ assert.equal(plainView.kind, 'notice');
411
+ assert.match(plainView.message, /No picture was requested/i);
412
+ assert.ok(plainView.guides.length > 0, 'the table survives the missing picture');
413
+
414
+ // ── figure-card fallbacks: never throw inside a transcript ──────────────────
415
+
416
+ assert.equal(panelCore.figureCardView({ kind: 'molbio-map' }).kind, 'notice', 'a map meta is not a figure');
417
+ assert.equal(panelCore.figureCardView(undefined).kind, 'notice');
418
+ assert.equal(panelCore.figureCardView(null).kind, 'notice');
419
+ assert.equal(panelCore.figureCardView('nonsense').kind, 'notice');
420
+ assert.equal(panelCore.figureCardView([]).kind, 'notice');
421
+ assert.equal(panelCore.figureCardView({ kind: 'molbio-figure', figure: 'unknown' }).kind, 'notice');
422
+ const oversizedFigure = panelCore.figureCardView({
423
+ kind: 'molbio-figure',
424
+ figure: 'logo',
425
+ summary: 'Sequence logo',
426
+ svg_omitted: true,
427
+ svg_bytes: 900_000,
428
+ svg_path: at('big-logo.svg'),
429
+ });
430
+ assert.equal(oversizedFigure.kind, 'notice');
431
+ assert.match(oversizedFigure.message, /too large/i);
432
+ assert.equal(oversizedFigure.svgPath, at('big-logo.svg'));
433
+ assert.equal(panelCore.figureCardView({ kind: 'molbio-figure', figure: 'logo', svg: '<div>hi</div>' }).kind, 'notice');
434
+ const sanitized = panelCore.figureCardView({
435
+ kind: 'molbio-figure',
436
+ figure: 'grna',
437
+ summary: 's',
438
+ svg_missing: true,
439
+ guides: [null, 7, { sequence: 42 }, { sequence: 'ACGT', rank: 'x', start: 1.5 }, { sequence: 'ACGT', rank: 1 }, { sequence: 'ACGT', rank: 2 }, { sequence: 'ACGT', rank: 3 }, { sequence: 'ACGT', rank: 4 }],
440
+ });
441
+ assert.equal(sanitized.guides.length, 5, 'malformed rows are dropped and the list is capped at five');
442
+ assert.equal(sanitized.guides[0].rank, 0, 'a non-integer rank degrades to 0 rather than being trusted');
443
+
444
+ // ── the figure component: logo and gRNA render through the same card ────────
445
+
446
+ const FigureCard = slots.registrations.find(
447
+ (entry) => entry.registration.name === 'tool.call.toolview' && entry.registration.key === 'molbio_sequence_logo',
448
+ ).component;
449
+ assert.equal(typeof FigureCard, 'function', 'the figure card is registered for molbio_sequence_logo');
450
+
451
+ globalThis.__molbioTestHooks = mount(FigureCard);
452
+ const logoCard = globalThis.__molbioTestHooks;
453
+ logoCard.setProps({ block: { kind: 'tool-result', callId: 'l1', isError: false, meta: logoMeta, content: [{ type: 'text', text: 'logo saved' }] }, inspect: undefined });
454
+ await logoCard.render();
455
+ const logoText = texts(logoCard.tree).join('\n');
456
+ assert.ok(logoText.includes('Sequence logo · 3 sequences × 16 columns'), 'the logo card header summarises the alignment');
457
+ assert.ok(logoText.includes(logoValue.svg_path), 'the logo card names the written file');
458
+ const logoHosts = (function find(node, out = []) {
459
+ if (Array.isArray(node)) {
460
+ for (const child of node) find(child, out);
461
+ return out;
462
+ }
463
+ if (node !== null && typeof node === 'object' && node.props !== undefined) {
464
+ if (node.props.ref !== undefined && node.props.ref !== null) out.push(node);
465
+ find(node.props.children, out);
466
+ }
467
+ return out;
468
+ })(logoCard.tree);
469
+ assert.equal(logoHosts.length, 1, 'the logo card mounts one SVG host');
470
+ assert.equal(logoHosts[0].props.ref.current.children.length, 1, 'and appended the parsed logo into it');
471
+ logoCard.unmount();
472
+
473
+ globalThis.__molbioTestHooks = mount(FigureCard);
474
+ const grnaCard = globalThis.__molbioTestHooks;
475
+ grnaCard.setProps({ block: { kind: 'tool-result', callId: 'g1', isError: false, meta: grnaMeta, content: [{ type: 'text', text: 'guides designed' }] }, inspect: undefined });
476
+ await grnaCard.render();
477
+ const grnaText = texts(grnaCard.tree).join('\n');
478
+ assert.ok(grnaText.includes('SpCas9'), 'the gRNA card header names the family');
479
+ assert.ok(grnaText.includes(grnaValue.guides[0].sequence), 'the gRNA card lists the top guide');
480
+ assert.ok(grnaText.includes('Off-targets'), 'the gRNA card shows the guide table header');
481
+ assert.ok(grnaText.includes(grnaValue.map_path), 'and the written map path');
482
+ assert.ok(grnaText.includes(grnaValue.saved_to), 'and the ordering CSV path');
483
+ grnaCard.unmount();
484
+
347
485
  await rm(shimPath, { force: true });
348
486
  await rm(entryPath, { force: true });
349
- console.log('map card checks passed (host projection -> card view -> rendered SVG)');
487
+ console.log('tool card checks passed (map + logo/gRNA: host projection -> card view -> rendered SVG)');
@@ -36,6 +36,7 @@
36
36
  */
37
37
  import { readFile, readdir } from 'node:fs/promises';
38
38
  import { existsSync } from 'node:fs';
39
+ import { createRequire } from 'node:module';
39
40
  import { dirname, join, resolve } from 'node:path';
40
41
  import { fileURLToPath, pathToFileURL } from 'node:url';
41
42
  // Re-exported for test/drift-probe.mjs, which drives the same reader against
@@ -101,6 +102,34 @@ function flattenRows(rows, at = '') {
101
102
  return found;
102
103
  }
103
104
 
105
+ /**
106
+ * Resolve a row's specifier the way the Loader does, or say why it cannot.
107
+ *
108
+ * The Loader imports a package row BY SPECIFIER (`import.meta.resolve` anchored
109
+ * in the config project's scope, see `cordis-plugin-loader`), and Node enforces
110
+ * the target package's `exports` map on that specifier. `packageInstalled` only
111
+ * proves that a directory exists, so a row naming a SUBPATH the harness dropped
112
+ * used to pass this suite and then fail at preset mount — that is exactly how
113
+ * `@deepseek-ai/dsh-tool-subagent-control/list-agents` (removed in 0.2.2-rc.1,
114
+ * where `list_agents` moved into the control plugin itself) survived until a
115
+ * manual run against the new harness.
116
+ *
117
+ * Exported so `test/drift-probe.mjs` can drive it against a mutated specifier:
118
+ * a guard nobody has seen fail is not a guard (rules.md rule 9).
119
+ *
120
+ * @param {string} name the row's `name`.
121
+ * @param {string} harnessRoot the installed harness.
122
+ * @returns {string|undefined} a diagnostic, or undefined when it resolves.
123
+ */
124
+ export function specifierProblem(name, harnessRoot) {
125
+ try {
126
+ createRequire(join(harnessRoot, 'package.json')).resolve(name);
127
+ return undefined;
128
+ } catch (error) {
129
+ return `specifier does not resolve: ${error?.code ?? error?.message ?? error}`;
130
+ }
131
+ }
132
+
104
133
  /** Validate one row against its package's own `Config` schema. */
105
134
  async function checkRow(row, packages, harnessRoot, presetBase) {
106
135
  const id = typeof row.id === 'string' ? row.id : row.name;
@@ -118,6 +147,16 @@ async function checkRow(row, packages, harnessRoot, presetBase) {
118
147
  : { ...result, status: 'unresolvable', detail: `preset-relative module not found: ${target}` };
119
148
  }
120
149
 
150
+ // A bare package row is imported by SPECIFIER, so its `exports` map decides
151
+ // whether it mounts. Check that before looking for the package directory:
152
+ // `packageInstalled` walks up from the harness root and would happily report a
153
+ // package whose named subpath no longer exists. `cordis:*` rows name Loader
154
+ // builtins, not modules, so they are skipped here too.
155
+ if (!row.name.includes(':') && row.name !== selfName) {
156
+ const problem = specifierProblem(row.name, harnessRoot);
157
+ if (problem !== undefined) return { ...result, status: 'unresolvable', detail: problem };
158
+ }
159
+
121
160
  const pkgDir = row.name === selfName
122
161
  // The row names THIS package. `packageInstalled` walks up from the harness
123
162
  // root, where this package is not installed — but in the preset it is
package/test/smoke.mjs CHANGED
@@ -1587,8 +1587,8 @@ function makeTemplate(n, seed = 42) {
1587
1587
 
1588
1588
  {
1589
1589
  // cross-intron primer design (fixture seeds chosen to pass the v12
1590
- // Primer3-style structural defaults)
1591
- const e1 = makeTemplate(120, 10);
1590
+ // Primer3-style structural defaults under the corrected orientation)
1591
+ const e1 = makeTemplate(120, 49);
1592
1592
  const e2 = makeTemplate(120, 14);
1593
1593
  const e3 = makeTemplate(120, 34);
1594
1594
  const genomic = e1 + makeTemplate(800, 29) + e2 + makeTemplate(800, 31) + e3;
@@ -1603,15 +1603,26 @@ function makeTemplate(n, seed = 42) {
1603
1603
  });
1604
1604
  assert.ok(out.pairs.length > 0, 'should find junction-spanning pairs');
1605
1605
  const pair = out.pairs[0];
1606
- assert.ok(pair.forward.junction_left >= 6 && pair.forward.junction_right >= 6);
1607
- assert.equal(pair.forward.exons.length, 2);
1608
- assert.equal(Math.abs(Number(pair.forward.exons[1]) - Number(pair.forward.exons[0])), 1);
1606
+ // 0.23.0: the junction is carried by the REVERSE primer (the assay sits 5' of
1607
+ // the junction); forward is upstream and inside one exon. The reverse sequence
1608
+ // is the molecule to order, i.e. the reverse complement of its spliced site.
1609
+ assert.ok(pair.reverse.junction_left >= 6 && pair.reverse.junction_right >= 6);
1610
+ assert.equal(pair.reverse.exons.length, 2);
1611
+ assert.equal(Math.abs(Number(pair.reverse.exons[1]) - Number(pair.reverse.exons[0])), 1);
1609
1612
  assert.ok(pair.genomic_amplicon_length >= 900);
1610
1613
  assert.ok(pair.spliced_amplicon.length >= 80 && pair.spliced_amplicon.length <= 200);
1611
- assert.ok(pair.reverse.exon !== Number(pair.forward.exons[1]));
1614
+ assert.ok(pair.forward.exon >= 1 && pair.forward.exon < Number(pair.reverse.exons[1]), 'forward sits in an earlier exon than the reverse primer\'s downstream exon');
1615
+ assert.ok(pair.forward.spliced_start < pair.reverse.spliced_start, 'F is upstream of R');
1616
+ assert.equal(pair.forward.spliced_start, pair.spliced_amplicon.start, 'amplicon opens at the forward 5\' end');
1617
+ assert.equal(pair.reverse.spliced_end, pair.spliced_amplicon.end, 'amplicon closes at the reverse 5\' end');
1612
1618
  const spliced = e1 + e2 + e3;
1613
1619
  assert.ok(spliced.includes(pair.forward.sequence), 'forward primer is a spliced-sequence substring');
1614
- assert.ok(spliced.includes(pair.reverse.sequence), 'reverse primer is a spliced-sequence substring');
1620
+ assert.equal(
1621
+ pair.reverse.sequence,
1622
+ lib.reverseComplement(spliced.slice(pair.reverse.spliced_start - 1, pair.reverse.spliced_end)),
1623
+ 'reverse primer is the reverse complement of its spliced site (the molecule to order)',
1624
+ );
1625
+ assert.ok(pair.reverse.spliced_start <= 120 && pair.reverse.spliced_end > 120, 'the reverse primer straddles the exon 1/2 junction');
1615
1626
  }
1616
1627
 
1617
1628
  // ── v12: mismatch tolerance in primer design ────────────────────────────────
@@ -1782,10 +1793,24 @@ function makeTemplate(n, seed = 42) {
1782
1793
  assert.equal(primer.mismatch_count, primer.mismatches.length);
1783
1794
  for (const m of primer.mismatches) {
1784
1795
  assert.equal(m.primer_base, primer.sequence[m.position - 1]);
1785
- assert.equal(m.template_base, spliced[m.spliced_position - 1], 'spliced position maps to the transcript base');
1796
+ // The perfect-match base is the transcript base for the forward primer and
1797
+ // its COMPLEMENT for the reverse primer (which is reported as the molecule
1798
+ // to order, i.e. the reverse complement of its spliced site).
1799
+ const transcriptBase = spliced[m.spliced_position - 1];
1800
+ assert.equal(m.template_base, isForward ? transcriptBase : lib.complement(transcriptBase), 'template_base is the perfect-match base on this strand');
1786
1801
  assert.equal(m.genomic_position, splicedToGenomic(m.spliced_position, exons), 'genomic position maps consistently');
1787
1802
  assert.ok(m.distance_from_3prime >= 1, 'no terminal-base mismatch in intron mode');
1788
1803
  }
1804
+ // The reported molecule must equal its spliced site (forward) or that
1805
+ // site's reverse complement (reverse) outside the reported mismatches.
1806
+ const expected = isForward
1807
+ ? spliced.slice(primer.spliced_start - 1, primer.spliced_end)
1808
+ : lib.reverseComplement(spliced.slice(primer.spliced_start - 1, primer.spliced_end));
1809
+ const positions = new Set(primer.mismatches.map((m) => m.position));
1810
+ for (let i = 0; i < expected.length; i++) {
1811
+ if (positions.has(i + 1)) continue;
1812
+ assert.equal(primer.sequence[i], expected[i], 'reported primer equals its site outside the reported mismatches');
1813
+ }
1789
1814
  }
1790
1815
  }
1791
1816
 
@@ -2133,8 +2158,10 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
2133
2158
  assert.equal(plain.pairs[0].target_distance, undefined, 'no target_distance without target_position');
2134
2159
  await assert.rejects(() => run('molbio_design_primers', { template, target_position: 601 }), /outside the template/);
2135
2160
  await assert.rejects(() => run('molbio_design_primers', { template, target_penalty: -1 }), /target_penalty/);
2136
- // cross-intron design reports spliced-coordinate target distances too
2137
- const e1 = makeTemplate(120, 10);
2161
+ // cross-intron design reports spliced-coordinate target distances too, and
2162
+ // (0.23.0) measures each one from the primer's actual 3' end: the forward's
2163
+ // highest coordinate and the reverse's LOWEST coordinate.
2164
+ const e1 = makeTemplate(120, 49);
2138
2165
  const e2 = makeTemplate(120, 14);
2139
2166
  const e3 = makeTemplate(120, 34);
2140
2167
  const genomic = e1 + makeTemplate(800, 29) + e2 + makeTemplate(800, 31) + e3;
@@ -2143,6 +2170,8 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
2143
2170
  assert.ok(intron.pairs.length > 0);
2144
2171
  for (const pair of intron.pairs) {
2145
2172
  assert.equal(pair.target_distance, Math.min(pair.forward.target_distance, pair.reverse.target_distance));
2173
+ assert.equal(pair.forward.target_distance, Math.abs(pair.forward.spliced_end - 150), 'forward distance is from its 3\' end');
2174
+ assert.equal(pair.reverse.target_distance, Math.abs(pair.reverse.spliced_start - 150), 'reverse distance is from its 3\' end');
2146
2175
  assert.ok(pair.forward.target_distance <= 359);
2147
2176
  }
2148
2177
  }
@@ -3323,6 +3352,7 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
3323
3352
  'molbio_grna_design', 'molbio_qpcr_efficiency', 'molbio_plot', 'molbio_virtual_gel',
3324
3353
  'molbio_sequence_logo', 'molbio_helical_wheel', 'molbio_hydropathy_plot',
3325
3354
  'molbio_fastq_qc', 'molbio_phylogenetic_tree', 'molbio_pcr_simulate', 'molbio_gc_composition',
3355
+ 'molbio_protein_charge', 'molbio_dotplot',
3326
3356
  ];
3327
3357
  for (const toolName of pictureTools) {
3328
3358
  const tool = toolByName(toolName);
@@ -3330,7 +3360,7 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
3330
3360
  assert.ok(Object.hasOwn(tool.output.schema.properties, 'image'), `${toolName} can report the attached image`);
3331
3361
  assert.ok(Object.hasOwn(tool.output.schema.properties, 'image_note'), `${toolName} can explain a missing image`);
3332
3362
  }
3333
- assert.equal(registered.filter((tool) => Object.hasOwn(tool.parameters.properties, 'attach_image')).length, 15, 'exactly the 15 picture tools — the report-only analysis tools are untouched');
3363
+ assert.equal(registered.filter((tool) => Object.hasOwn(tool.parameters.properties, 'attach_image')).length, 17, 'exactly the 17 picture tools — the report-only analysis tools are untouched');
3334
3364
 
3335
3365
  // 9. Rendering text alone still works (the attachment must not break render).
3336
3366
  const logo = await runWith('molbio_sequence_logo', { alignment: ['ACGTACGT', 'ACGTTCGT'], attach_image: true }, routedExec);
@@ -3447,6 +3477,22 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
3447
3477
  assert.equal(adapterUncapped.adapter.per_base_percent[23], 12.5, 'through its last base at position 24');
3448
3478
  assert.equal(adapterUncapped.adapter.per_base_percent[24], 0, 'and stops there');
3449
3479
 
3480
+ // 6b. The Small RNA 5' entry is documented as RNA; the table is DNA-normalized
3481
+ // so BOTH the forward molecule and its reverse complement are detectable in
3482
+ // cDNA reads. Before the fix only one of these two reads matched.
3483
+ const smallRna = 'GTTCAGAGTTCTACAGTCCGACGATC';
3484
+ const smallRnaFastq = [
3485
+ '@rna_f', 'ACGT' + smallRna + 'ACGT', '+', 'I'.repeat(8 + smallRna.length),
3486
+ '@rna_r', 'ACGT' + reverseComplementOf(smallRna) + 'ACGT', '+', 'I'.repeat(8 + smallRna.length),
3487
+ '',
3488
+ ].join('\n');
3489
+ const qcRna = await run('molbio_fastq_qc', { fastq: smallRnaFastq, adapter_scan_reads: 10, max_plot_bases: 100 });
3490
+ assert.deepEqual(
3491
+ qcRna.adapter.hits.map((hit) => [hit.name, hit.reads, hit.percent]),
3492
+ [["Illumina Small RNA 5'", 2, 100]],
3493
+ 'the DNA-normalized Small RNA adapter matches the forward and reverse-complement reads',
3494
+ );
3495
+
3450
3496
  // 7. Over-representation names the threshold that produced it — and on a
3451
3497
  // sample this small that threshold legitimately yields nothing, because
3452
3498
  // the bar is max(20 reads, 0.1% of the sample).
@@ -4187,6 +4233,237 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
4187
4233
  await assert.rejects(() => run('molbio_gc_composition', { sequence: 'ACGTACGTAC', window: 5 }), /window must be an integer of at least 10/);
4188
4234
  }
4189
4235
 
4236
+ // ── 0.23.0: protein charge vs pH ───────────────────────────────────────────
4237
+ {
4238
+ // KKDD has two positive and two negative side chains plus the termini, so the
4239
+ // two ends of the curve are exactly +3 and -3 (within the 1e-4 sampling
4240
+ // rounding) and the pI is the zero crossing between them.
4241
+ const sequence = 'KKDD';
4242
+ const props = await run('molbio_protein_props', { sequence });
4243
+ const out = await run('molbio_protein_charge', { sequence, output_path: at('charge.svg') });
4244
+ assert.equal(out.length, 4);
4245
+ assert.equal(out.pka_set, 'bjellqvist');
4246
+ assert.equal(out.pka_label, 'Bjellqvist 1993');
4247
+ assert.equal(out.pI, props.pi, 'the default pK set matches molbio_protein_props');
4248
+ assert.equal(out.points.length, 29);
4249
+ assert.equal(out.points[0].ph, 0);
4250
+ assert.equal(out.points[28].ph, 14);
4251
+ assert.ok(Math.abs(out.points[0].charge - 3) < 0.01, 'pH 0: N-terminus + 2 Lys = +3');
4252
+ assert.ok(Math.abs(out.points[28].charge + 3) < 0.01, 'pH 14: C-terminus + 2 Asp = -3');
4253
+ assert.ok(Math.abs(out.charge_at_7_4 + 0.0591) < 0.01);
4254
+ // The pI must lie between the two sampled pH values whose charges bracket 0.
4255
+ let bracketed = false;
4256
+ for (let i = 1; i < out.points.length; i++) {
4257
+ const a = out.points[i - 1];
4258
+ const b = out.points[i];
4259
+ if ((a.charge >= 0 && b.charge <= 0) || (a.charge <= 0 && b.charge >= 0)) {
4260
+ assert.ok(out.pI >= a.ph && out.pI <= b.ph, 'pI is the zero crossing');
4261
+ bracketed = true;
4262
+ break;
4263
+ }
4264
+ }
4265
+ assert.ok(bracketed, 'the sampled curve changes sign');
4266
+ assert.ok(memFs.files.get(at('charge.svg')).includes('<svg'), 'the titration SVG is written');
4267
+
4268
+ // A different pK table is a different answer, and it says which one it used.
4269
+ const emboss = await run('molbio_protein_charge', { sequence, pka_set: 'emboss', output_path: at('charge-emboss.svg') });
4270
+ assert.equal(emboss.pka_set, 'emboss');
4271
+ assert.equal(emboss.pka_label, 'EMBOSS Epk.dat');
4272
+ assert.notEqual(emboss.pI, out.pI, 'the pK set changes the pI, so the set name must travel with it');
4273
+
4274
+ // Errors: range checks in the module, enum check by the argument schema.
4275
+ await assert.rejects(() => run('molbio_protein_charge', { sequence, ph_min: 15 }), /ph_min/);
4276
+ await assert.rejects(() => run('molbio_protein_charge', { sequence, ph_min: 8, ph_max: 7 }), /greater than ph_min/);
4277
+ await assert.rejects(() => run('molbio_protein_charge', { sequence, steps: 1 }), /steps/);
4278
+ await assert.rejects(() => run('molbio_protein_charge', { sequence, pka_set: 'nonsense' }), /must be one of/);
4279
+ await assert.rejects(() => run('molbio_protein_charge', { sequence: 'ACGTX' }), /invalid character/);
4280
+ }
4281
+
4282
+ // ── 0.23.0: sequence dotplot ────────────────────────────────────────────────
4283
+ {
4284
+ // Two 12 bp copies of the same word separated by an 8 bp T stretch: with a
4285
+ // 4-mer word every k-mer in one copy matches the same k-mer in the other.
4286
+ const sequence = 'ACGTACGTACGT' + 'TTTTTTTT' + 'ACGTACGTACGT';
4287
+ const out = await run('molbio_dotplot', { sequence, word: 4, output_path: at('dot.svg') });
4288
+ assert.equal(out.length, 32);
4289
+ assert.equal(out.word, 4);
4290
+ assert.equal(out.mode, 'both');
4291
+ assert.equal(out.direct_matches, 133);
4292
+ assert.equal(out.inverted_matches, 40);
4293
+ assert.equal(out.direct_cells, out.direct_matches, 'this small fixture is under one dot per grid cell');
4294
+ assert.equal(out.truncated, false);
4295
+ assert.equal(out.plot_truncated, false);
4296
+ const dotSvg = memFs.files.get(at('dot.svg'));
4297
+ assert.ok(dotSvg.includes('<svg'), 'the dotplot SVG is written');
4298
+ // Layout guard: the x-axis title and the legend both live BELOW the plot but
4299
+ // inside the panel. The first version put them on/outside the bottom border
4300
+ // (720 px canvas, 628 px square plot); a plain "contains <svg>" check would
4301
+ // pass while the footer was clipped, so assert the vertical budget instead.
4302
+ const dotView = /viewBox="0 0 (\d+) (\d+)"/.exec(dotSvg);
4303
+ assert.ok(dotView !== null, 'the dotplot declares a viewBox');
4304
+ const dotHeight = Number(dotView[2]);
4305
+ const dotTexts = [...dotSvg.matchAll(/<text ([^>]*)>([^<]*)<\/text>/g)]
4306
+ .map((match) => ({ attrs: match[1], text: match[2] }));
4307
+ const yOf = (attrs) => Number(/y="([0-9.]+)"/.exec(attrs)[1]);
4308
+ const xTitle = dotTexts.find((node) => node.text === 'position (bp)' && !node.attrs.includes('transform='));
4309
+ const directLegend = dotTexts.find((node) => node.text === 'direct repeat');
4310
+ assert.ok(xTitle !== undefined, 'the x axis is titled');
4311
+ assert.ok(directLegend !== undefined, 'the direct-repeat legend is drawn');
4312
+ assert.ok(yOf(xTitle.attrs) <= dotHeight - 30, 'the x-axis title is not sitting on the bottom border');
4313
+ assert.ok(yOf(directLegend.attrs) <= dotHeight - 20, 'the legend is inside the canvas');
4314
+ assert.ok(yOf(directLegend.attrs) > yOf(xTitle.attrs), 'the legend sits below the x-axis title, not on top of it');
4315
+
4316
+ // Inverted-only mode does not report direct matches, and an inverted repeat of
4317
+ // the first block shows up as 12 exact 4-mer hits.
4318
+ const inverted = await run('molbio_dotplot', { sequence: 'AAAACCCC' + 'GGGGTTTT', word: 4, mode: 'inverted' });
4319
+ assert.equal(inverted.direct_matches, 0);
4320
+ assert.equal(inverted.inverted_matches, 12);
4321
+ assert.equal(inverted.inverted_cells, 12);
4322
+
4323
+ // Ambiguous k-mers are skipped and counted, never silently matched.
4324
+ const ambiguous = await run('molbio_dotplot', { sequence: 'ACGTNNNNACGTACGT', word: 4 });
4325
+ assert.equal(ambiguous.ambiguous_bases, 7, 'every 4-mer window touching the N run is skipped');
4326
+
4327
+ // The work cap turns a homopolymer into a bounded, explicitly truncated result.
4328
+ const capped = await run('molbio_dotplot', { sequence: 'A'.repeat(600), word: 4 });
4329
+ assert.equal(capped.truncated, true, 'the scan is capped rather than hanging');
4330
+ assert.ok(capped.direct_matches <= 200_000);
4331
+
4332
+ // Determinism: the same input produces the same counts.
4333
+ const again = await run('molbio_dotplot', { sequence, word: 4 });
4334
+ assert.deepEqual(
4335
+ [again.direct_matches, again.inverted_matches, again.direct_cells, again.inverted_cells],
4336
+ [out.direct_matches, out.inverted_matches, out.direct_cells, out.inverted_cells],
4337
+ );
4338
+
4339
+ await assert.rejects(() => run('molbio_dotplot', { sequence, word: 3 }), /word/);
4340
+ await assert.rejects(() => run('molbio_dotplot', { sequence, word: 13 }), /word/);
4341
+ await assert.rejects(() => run('molbio_dotplot', { sequence: 'ACGTACGT', word: 8 }), /at least/);
4342
+ await assert.rejects(() => run('molbio_dotplot', { sequence, mode: 'diagonal' }), /must be one of/);
4343
+ }
4344
+
4345
+
4346
+ // ── 0.24.0: contamination screening + FASTQ trimming ─────────────────────────
4347
+ {
4348
+ // Two known embedded signatures: a pUC/pBR322 origin fragment at the start and
4349
+ // the Illumina Universal adapter at the end. The middle is a neutral repeat.
4350
+ const contaminated = 'GTTTGCCGGATCAAGAGCTACCAACTCTTT' + 'ACGT'.repeat(20) + 'AGATCGGAAGAG';
4351
+ const screen = await run('molbio_contamination_screen', { sequence: contaminated });
4352
+ assert.equal(screen.input_type, 'sequence');
4353
+ assert.equal(screen.total_entries, 1);
4354
+ assert.equal(screen.hit_count, 2);
4355
+ assert.deepEqual(screen.hits.map((hit) => hit.name), ['Illumina Universal', 'pUC/pBR322 origin']);
4356
+ const pucHit = screen.hits.find((hit) => hit.name === 'pUC/pBR322 origin');
4357
+ assert.deepEqual(
4358
+ [pucHit.kind, pucHit.category, pucHit.example.start, pucHit.example.end, pucHit.example.strand, pucHit.best_mismatches],
4359
+ ['vector', 'origin', 1, 30, '+', 0],
4360
+ );
4361
+ const adapterHit = screen.hits.find((hit) => hit.name === 'Illumina Universal');
4362
+ assert.deepEqual([adapterHit.example.start, adapterHit.example.end, adapterHit.example.strand], [111, 122, '+']);
4363
+
4364
+ // A reverse-strand hit is reported on the forward coordinate system.
4365
+ const t7 = 'TAATACGACTCACTATAGGG';
4366
+ const reverse = await run('molbio_contamination_screen', { sequence: 'ACGT'.repeat(10) + reverseComplementOf(t7) });
4367
+ const t7Hit = reverse.hits.find((hit) => hit.name === 'pET T7 promoter');
4368
+ assert.equal(t7Hit.example.strand, '-');
4369
+ assert.equal(t7Hit.example.start, 41);
4370
+
4371
+ // One mismatch is invisible at max_mismatches 0 and found at 1. The mutated
4372
+ // pUC signature still leaves the adapter hit in place.
4373
+ const mutated = contaminated.replace('GTTTGCCGGATCAAGAGCTACCAACTCTTT', 'GTTTGCCGGATCAAGAGCTACCAACTCTTA');
4374
+ const exact = await run('molbio_contamination_screen', { sequence: mutated });
4375
+ assert.equal(exact.hits.some((hit) => hit.name === 'pUC/pBR322 origin'), false);
4376
+ const loose = await run('molbio_contamination_screen', { sequence: mutated, max_mismatches: 1 });
4377
+ const loosePuc = loose.hits.find((hit) => hit.name === 'pUC/pBR322 origin');
4378
+ assert.equal(loosePuc.best_mismatches, 1);
4379
+ assert.equal(loosePuc.example.mismatches, 1);
4380
+
4381
+ // FASTA/FASTQ inputs are scanned entry by entry, and max_reads caps the work.
4382
+ memFs.files.set(at('contam.fa'), '>a\n' + contaminated + '\n');
4383
+ const fromFasta = await run('molbio_contamination_screen', { path: at('contam.fa') });
4384
+ assert.equal(fromFasta.input_type, 'fasta');
4385
+ assert.equal(fromFasta.scanned_entries, 1);
4386
+ memFs.files.set(at('contam.fq'), '@a\n' + contaminated + '\n+\n' + 'I'.repeat(contaminated.length) + '\n');
4387
+ const fromFastq = await run('molbio_contamination_screen', { path: at('contam.fq') });
4388
+ assert.equal(fromFastq.input_type, 'fastq');
4389
+ assert.equal(fromFastq.hits.length, 2);
4390
+ const capped = await run('molbio_contamination_screen', { path: at('contam.fa'), max_reads: 1 });
4391
+ assert.equal(capped.scanned_entries, 1);
4392
+ assert.equal(capped.truncated, false);
4393
+
4394
+ await assert.rejects(() => run('molbio_contamination_screen', { sequence: contaminated, path: at('contam.fa') }), /exactly one/);
4395
+ await assert.rejects(() => run('molbio_contamination_screen', {}), /exactly one/);
4396
+ await assert.rejects(() => run('molbio_contamination_screen', { sequence: contaminated, kinds: 'plasmid' }), /must be one of/);
4397
+
4398
+ // ── FASTQ trim: known per-read outcomes ─────────────────────────────────
4399
+ const trimReadsInput = [
4400
+ '@r1', 'ACGT'.repeat(10), '+', 'I'.repeat(30) + '!'.repeat(10),
4401
+ '@r2', 'ACGT'.repeat(7) + 'AC' + 'AGATCGGAAGAG' + 'TTTTT', '+', 'I'.repeat(47),
4402
+ '@r3', 'ACGT'.repeat(5) + 'G'.repeat(10), '+', 'I'.repeat(30),
4403
+ '@r4', 'ACGT'.repeat(2) + 'AC', '+', 'I'.repeat(10),
4404
+ '',
4405
+ ].join('\n');
4406
+ memFs.files.set(at('trim-in.fq'), trimReadsInput);
4407
+ const trim = await run('molbio_fastq_trim', {
4408
+ path: at('trim-in.fq'),
4409
+ output_path: at('trim-out.fq'),
4410
+ min_quality: 30,
4411
+ quality_window: 3,
4412
+ poly_g: true,
4413
+ adapter: 'AGATCGGAAGAG',
4414
+ min_length: 20,
4415
+ });
4416
+ assert.equal(trim.reads_in, 4);
4417
+ assert.equal(trim.reads_out, 3);
4418
+ assert.equal(trim.bases_in, 127);
4419
+ assert.equal(trim.bases_out, 80);
4420
+ assert.equal(trim.trimmed_reads, 3);
4421
+ assert.equal(trim.dropped_reads, 1);
4422
+ assert.deepEqual(trim.trim_counts, { quality: 1, adapter: 1, poly_g: 1, poly_a: 0, primer: 0 });
4423
+ assert.deepEqual(trim.drop_counts, { length: 1, n: 0 });
4424
+ assert.equal(trim.output_format, 'fastq');
4425
+ assert.equal(trim.saved_to, at('trim-out.fq'));
4426
+ const trimmedText = memFs.files.get(at('trim-out.fq'));
4427
+ assert.equal(trimmedText.split('\n').filter((line) => line.startsWith('@')).length, 3);
4428
+ const trimmedLines = trimmedText.trimEnd().split('\n');
4429
+ assert.equal(trimmedLines.length, 12, 'three four-line FASTQ records');
4430
+ for (let index = 0; index < trimmedLines.length; index += 4) {
4431
+ assert.equal(trimmedLines[index + 2], '+');
4432
+ assert.equal(trimmedLines[index + 1].length, trimmedLines[index + 3].length, 'quality length follows the trimmed sequence');
4433
+ }
4434
+ assert.ok(trimmedLines.includes('ACGTACGTACGTACGTACGT'), 'r3 lost exactly its 10-base poly-G tail');
4435
+
4436
+ const fastaTrim = await run('molbio_fastq_trim', {
4437
+ path: at('trim-in.fq'),
4438
+ output_path: at('trim-out.fa'),
4439
+ output_format: 'fasta',
4440
+ min_quality: 30,
4441
+ quality_window: 3,
4442
+ poly_g: true,
4443
+ adapter: 'AGATCGGAAGAG',
4444
+ min_length: 20,
4445
+ });
4446
+ assert.equal(fastaTrim.output_format, 'fasta');
4447
+ assert.ok(memFs.files.get(at('trim-out.fa')).startsWith('>r1\n'));
4448
+
4449
+ // Phred+64: 'h' is Q40 under that encoding; a high-quality read survives.
4450
+ memFs.files.set(at('trim64.fq'), '@p64\nACGTACGTACGT\n+\nhhhhhhhhhhhh\n');
4451
+ const phred64 = await run('molbio_fastq_trim', {
4452
+ path: at('trim64.fq'),
4453
+ output_path: at('trim64-out.fq'),
4454
+ phred: 64,
4455
+ min_quality: 30,
4456
+ quality_window: 3,
4457
+ });
4458
+ assert.equal(phred64.reads_out, 1);
4459
+ assert.equal(phred64.trim_counts.quality, 0);
4460
+
4461
+ await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq') }), /output_path/);
4462
+ await assert.rejects(() => run('molbio_fastq_trim', { path: at('seqs.fa'), output_path: at('x.fq') }), /FASTA/);
4463
+ await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq'), output_path: at('x.fq'), min_quality: 61 }), /min_quality/);
4464
+ await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq'), output_path: at('x.fq'), adapter: 'ACGTX' }), /invalid/);
4465
+ }
4466
+
4190
4467
  // ── plugin surface ──────────────────────────────────────────────────────────
4191
4468
 
4192
4469
  assert.equal(plugin.name, 'dsh-molbio-tools');