dsh-molbio-tools 0.24.0 → 0.24.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/CHANGELOG.md +48 -0
- package/batch.mjs +5 -1
- package/benchmark/changed.mjs +4 -3
- package/benchmark/profile.mjs +1 -1
- package/build/client-bundle.mjs +1 -1
- package/build/preset-patch.mjs +1 -1
- package/build/release-preflight.mjs +1 -1
- package/charts.mjs +35 -16
- package/cordis.patch.yml +1 -1
- package/docs/client-panel.md +1 -1
- package/docs/history.md +24 -0
- package/docs/maintainer.md +1 -1
- package/docs/roadmap.md +5 -1
- package/docs/store-evidence.md +2 -2
- package/fastq-qc.mjs +25 -7
- package/host.mjs +2 -2
- package/index.mjs +51 -10
- package/lib/client.js +49 -16
- package/package.json +1 -1
- package/papers.mjs +10 -0
- package/plot.mjs +24 -8
- package/preset/install.mjs +8 -0
- package/protein-structure.mjs +10 -2
- package/records.mjs +17 -2
- package/seqio.mjs +39 -14
- package/test/charts.mjs +11 -0
- package/test/smoke.mjs +90 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,54 @@
|
|
|
10
10
|
改为按**包名**引用后模块缓存问题随之消失——没有拷贝,也就没有需要保持同步的版本目录。
|
|
11
11
|
下文历史条目里出现的 `vN` 目录保持原样,作为当时的记录。
|
|
12
12
|
|
|
13
|
+
## [0.24.1] — 2026-11-08(输入边界与数据安全加固;工具数不变)
|
|
14
|
+
|
|
15
|
+
**不新增工具:工具数保持 64,benchmark 保持 65 题 / 64 覆盖。** 这一版只修“已经在产品里、
|
|
16
|
+
但不会报错的错”。面板包 `0.1.9 → 0.1.10`:两个客户端产物因 `seqio.mjs` /
|
|
17
|
+
`protein-structure.mjs` 的加固改动而重建,按仓库规则必须升版本号,否则会用同一版本发不同字节。
|
|
18
|
+
|
|
19
|
+
### 1. 数据安全与路径语义
|
|
20
|
+
|
|
21
|
+
- `molbio_paper_export_bibtex`:自定义 `file` 不以 `.json` 结尾时,旧逻辑把库路径本身当
|
|
22
|
+
输出路径,BibTeX 会**覆盖文献库**。现在始终派生独立的 `.bib`(`refs.txt` →
|
|
23
|
+
`refs.txt.bib`),库文件保持不变。
|
|
24
|
+
- 相对路径读取改为按**会话工作区**解析(`sandboxPolicy.resolve(...).workspaceRoot`),
|
|
25
|
+
不再落到 provider 的 `process.cwd()`;绝对路径行为不变。
|
|
26
|
+
- 文献库/记录库加载时拒绝 `null` 或标量条目,返回带文件名的输入错误,而不是
|
|
27
|
+
`paperKey()` / `record.name.trim()` 抛出的原始 `TypeError`;协议重命名与新增使用
|
|
28
|
+
同一条重名规则;`molbio_paper_update` 在标题/URL/PMID 变化后重算派生 id。
|
|
29
|
+
- `molbio_batch_analyze`:显式 `paths` 也受 `max_files` 约束;无法解析的路径变成一条
|
|
30
|
+
错误行而不是整批失败;glob 先转义反斜杠,`.` 不再被当通配符(`a.fa` 不会匹配
|
|
31
|
+
`aXfa`)。
|
|
32
|
+
|
|
33
|
+
### 2. 并发声明与大输入
|
|
34
|
+
|
|
35
|
+
- 四个总会写默认 SVG 的工具——`molbio_helical_wheel`、`molbio_hydropathy_plot`、
|
|
36
|
+
`molbio_protein_charge`、`molbio_dotplot`——以及带 `output_path` 的
|
|
37
|
+
`molbio_fasta_fastq extract` 改为 `safe: false`。旧谓词在 `output_path` 省略时
|
|
38
|
+
声称并发安全,而默认文件仍会被并发调用互相覆盖。
|
|
39
|
+
- 图表/FASTQ/蛋白剖面不再把用户规模数组展开进 `Math.min(...)`/`Math.max(...)`:
|
|
40
|
+
200,000 行表格、130,000 条 reads 与长蛋白在文档声明的上限内不再抛 `RangeError`;
|
|
41
|
+
FASTQ 汇总改为单次流式计数,不再物化全部 Phred 值。
|
|
42
|
+
- `molbio_pubmed_abstract` 的 efetch 传输失败改为具名输入错误,不再让原始异常逃出工具。
|
|
43
|
+
|
|
44
|
+
### 3. 发布与仓库卫生
|
|
45
|
+
|
|
46
|
+
- `.github/workflows/test.yml` 增加 `permissions: contents: read`;
|
|
47
|
+
`.gitignore` 忽略 `.codex-tmp/`。
|
|
48
|
+
- `preset/install.mjs` 校验 `--profile`(字母/数字开头,仅 `._-`),避免 profile 名
|
|
49
|
+
经 Windows shell 时被当作命令元字符。
|
|
50
|
+
- 同步多处注释里过时的工具数(57/60/62 → 64)。
|
|
51
|
+
|
|
52
|
+
### 4. 验证与边界
|
|
53
|
+
|
|
54
|
+
- `npm test` 全绿:smoke 64 工具、benchmark 65 题 / 64 覆盖、`preset-health` 29 行、
|
|
55
|
+
`release` 13 项;`build/client-bundle.mjs --check` 与 `build/preset-patch.mjs --check`
|
|
56
|
+
与源一致。
|
|
57
|
+
- 不新增工具、不改 preset 行清单、不改 DSH 基线(0.2.2-rc.1)。
|
|
58
|
+
- `benchmark-changed` 的 POSIX 路径断言在本地沙箱因 Node 无法 spawn `git` 未执行;
|
|
59
|
+
CI 会执行。这是沙箱限制,不是仓库缺陷。
|
|
60
|
+
|
|
13
61
|
## [0.24.0] — 2026-11-05(载体/接头污染筛查;FASTQ 修剪;接头表静默错修复)
|
|
14
62
|
|
|
15
63
|
**工具数 62 → 64,benchmark 63 → 65 题(64/64 覆盖)。** 两块工作都来自候选池 survey:
|
package/batch.mjs
CHANGED
|
@@ -46,7 +46,11 @@ export function detectBatchFormat(path, text) {
|
|
|
46
46
|
/** Convert a simple basename glob (*, ?) to a RegExp. */
|
|
47
47
|
export function globToRegExp(pattern) {
|
|
48
48
|
const source = String(pattern)
|
|
49
|
-
|
|
49
|
+
// A literal backslash must be escaped before anything else; otherwise the
|
|
50
|
+
// following replaceAll calls turn it into a regex escape instead of a
|
|
51
|
+
// filename character.
|
|
52
|
+
.replaceAll('\\', '\\\\')
|
|
53
|
+
.replace(/[.+^$()|[\]{}]/g, '\\$&')
|
|
50
54
|
.replaceAll('*', '.*')
|
|
51
55
|
.replaceAll('?', '.');
|
|
52
56
|
return new RegExp('^' + source + '$', 'i');
|
package/benchmark/changed.mjs
CHANGED
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
* consequences of a helper module being imported by feature modules — a real
|
|
20
20
|
* dependency, but not the tool's IMPLEMENTATION.
|
|
21
21
|
*
|
|
22
|
-
* Measured, not guessed: 26 of the
|
|
23
|
-
* module
|
|
24
|
-
*
|
|
22
|
+
* Measured, not guessed: 26 of the tools call bindings from more than one
|
|
23
|
+
* module (measured when the suite covered 57; the graph property, not the
|
|
24
|
+
* count, is what matters). So a per-file → tool map is not a partition, it is
|
|
25
|
+
* a graph, and the honest model is:
|
|
25
26
|
*
|
|
26
27
|
* changed file → the module(s) it belongs to
|
|
27
28
|
* → every module that transitively depends on them
|
package/benchmark/profile.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ── Why a hand-built profile exists at all ──────────────────────────────────
|
|
7
7
|
*
|
|
8
|
-
* The model-facing way to get the
|
|
8
|
+
* The model-facing way to get the 64 tools is the "Molecular Biology Lab" agent
|
|
9
9
|
* preset, and the benchmark would obviously rather run THAT than a
|
|
10
10
|
* reconstruction of it. It cannot: `dsh-headless` refuses a preset for a
|
|
11
11
|
* structural reason, not a configuration one —
|
package/build/client-bundle.mjs
CHANGED
|
@@ -40,7 +40,7 @@ const checkOnly = process.argv.slice(2).includes('--check');
|
|
|
40
40
|
/**
|
|
41
41
|
* The bundles to emit. The SAME browser half ships through two channels:
|
|
42
42
|
*
|
|
43
|
-
* - `dsh-molbio-tools` — the plugin package: one install gives the
|
|
43
|
+
* - `dsh-molbio-tools` — the plugin package: one install gives the 64 tools
|
|
44
44
|
* plus the panel (its host half also registers the tools).
|
|
45
45
|
* - `dsh-molbio-panel` — a panel-only package: installing it adds the tab to a
|
|
46
46
|
* profile WITHOUT dragging the tools into every session of that profile,
|
package/build/preset-patch.mjs
CHANGED
|
@@ -101,7 +101,7 @@ const ANCHOR = [
|
|
|
101
101
|
'#',
|
|
102
102
|
'# Keep the tool in the PRESET and only the anchor here. An earlier version of',
|
|
103
103
|
"# this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),",
|
|
104
|
-
"# which both registered
|
|
104
|
+
"# which both registered every tool globally and collided with the preset's own",
|
|
105
105
|
'# copy of that row:',
|
|
106
106
|
'#',
|
|
107
107
|
'# broken: "tool-molbio (...): never started"',
|
|
@@ -41,7 +41,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
41
41
|
* The two npm entries this repository publishes, in publish order.
|
|
42
42
|
*
|
|
43
43
|
* THEY DO NOT SHARE A VERSION LINE, and that is deliberate rather than an
|
|
44
|
-
* oversight to be "fixed" here. The root package carries the
|
|
44
|
+
* oversight to be "fixed" here. The root package carries the 64 tools and moves
|
|
45
45
|
* with every toolset release; `dsh-molbio-panel` carries only the browser panel
|
|
46
46
|
* and moves when the panel's own artifact moves. The tag names the ROOT version
|
|
47
47
|
* (`v0.19.0`), and each entry is checked against its own declared version —
|
package/charts.mjs
CHANGED
|
@@ -60,6 +60,25 @@ import {
|
|
|
60
60
|
textRun,
|
|
61
61
|
} from './svgio.mjs';
|
|
62
62
|
|
|
63
|
+
// ── data-sized min/max ──────────────────────────────────────────────────────
|
|
64
|
+
//
|
|
65
|
+
// A table this module accepts can hold MAX_ROWS (200 000) values. Spreading an
|
|
66
|
+
// array that large into Math.min/Math.max overflows the engine's argument limit
|
|
67
|
+
// with a RangeError, so the data-sized extrema are computed with loops. Fixed-
|
|
68
|
+
// size arrays (bins, colour stops, density grids) still use the spread form.
|
|
69
|
+
|
|
70
|
+
function minData(values) {
|
|
71
|
+
let min = Infinity;
|
|
72
|
+
for (const value of values) if (value < min) min = value;
|
|
73
|
+
return min;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function maxData(values) {
|
|
77
|
+
let max = -Infinity;
|
|
78
|
+
for (const value of values) if (value > max) max = value;
|
|
79
|
+
return max;
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
// ── canvas ──────────────────────────────────────────────────────────────────
|
|
64
83
|
|
|
65
84
|
export const CHART_WIDTH = 720;
|
|
@@ -130,8 +149,8 @@ export function histogramBins(values, requestedBins) {
|
|
|
130
149
|
const finite = values.filter((value) => Number.isFinite(value));
|
|
131
150
|
if (finite.length === 0) throw new MolbioInputError('a histogram needs at least one finite value');
|
|
132
151
|
const n = finite.length;
|
|
133
|
-
const min =
|
|
134
|
-
const max =
|
|
152
|
+
const min = minData(finite);
|
|
153
|
+
const max = maxData(finite);
|
|
135
154
|
let binCount;
|
|
136
155
|
let rule;
|
|
137
156
|
if (requestedBins !== undefined) {
|
|
@@ -634,9 +653,9 @@ export function renderBoxPlot({ title, x_label, y_label, series }) {
|
|
|
634
653
|
const stats = boxStats(validateFinite(entry.values, `series ${index + 1} values`));
|
|
635
654
|
return { label, stats, color: entry.color ?? SERIES_COLORS[index % SERIES_COLORS.length] };
|
|
636
655
|
});
|
|
637
|
-
const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low,
|
|
638
|
-
const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high,
|
|
639
|
-
const [domainMin, domainMax] = padDomain(
|
|
656
|
+
const lows = prepared.map((entry) => Math.min(entry.stats.whisker_low, minData(entry.stats.outliers), entry.stats.min));
|
|
657
|
+
const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, maxData(entry.stats.outliers), entry.stats.max));
|
|
658
|
+
const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
|
|
640
659
|
const { parts, plot } = frame(title);
|
|
641
660
|
const y = yAxis(parts, plot, domainMin, domainMax, y_label);
|
|
642
661
|
const slot = plot.width / prepared.length;
|
|
@@ -695,8 +714,8 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
|
|
|
695
714
|
});
|
|
696
715
|
const allX = prepared.flatMap((entry) => entry.xs);
|
|
697
716
|
const allY = prepared.flatMap((entry) => entry.ys);
|
|
698
|
-
const [xMin, xMax] = padDomain(
|
|
699
|
-
const [yMin, yMax] = padDomain(
|
|
717
|
+
const [xMin, xMax] = padDomain(minData(allX), maxData(allX));
|
|
718
|
+
const [yMin, yMax] = padDomain(minData(allY), maxData(allY));
|
|
700
719
|
const { parts, plot } = frame(title);
|
|
701
720
|
const y = yAxis(parts, plot, yMin, yMax, y_label);
|
|
702
721
|
const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
|
|
@@ -719,11 +738,11 @@ export function renderLineChart({ title, x_label, y_label, series, markers = tru
|
|
|
719
738
|
return {
|
|
720
739
|
svg,
|
|
721
740
|
stats: {
|
|
722
|
-
series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min:
|
|
741
|
+
series: prepared.map((entry) => ({ label: entry.label, points: entry.xs.length, x_min: minData(entry.xs), x_max: maxData(entry.xs), y_min: minData(entry.ys), y_max: maxData(entry.ys) })),
|
|
723
742
|
series_count: prepared.length,
|
|
724
743
|
point_count: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
|
|
725
|
-
x_range: [
|
|
726
|
-
y_range: [
|
|
744
|
+
x_range: [minData(allX), maxData(allX)],
|
|
745
|
+
y_range: [minData(allY), maxData(allY)],
|
|
727
746
|
},
|
|
728
747
|
marks: prepared.reduce((sum, entry) => sum + entry.xs.length, 0),
|
|
729
748
|
plot,
|
|
@@ -815,7 +834,7 @@ export function renderViolinPlot({ title, x_label, y_label, series, bandwidth, s
|
|
|
815
834
|
});
|
|
816
835
|
const lows = prepared.map((entry) => entry.stats.min);
|
|
817
836
|
const highs = prepared.map((entry) => entry.stats.max);
|
|
818
|
-
const [domainMin, domainMax] = padDomain(
|
|
837
|
+
const [domainMin, domainMax] = padDomain(minData(lows), maxData(highs));
|
|
819
838
|
const { parts, plot } = frame(title);
|
|
820
839
|
const y = yAxis(parts, plot, domainMin, domainMax, y_label);
|
|
821
840
|
const slot = plot.width / prepared.length;
|
|
@@ -890,8 +909,8 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
|
|
|
890
909
|
const significant = (point) => Math.abs(point.fold) >= fold_change_threshold && point.p <= p_threshold;
|
|
891
910
|
const xs = prepared.map((point) => point.fold);
|
|
892
911
|
const ys = prepared.map((point) => point.significance);
|
|
893
|
-
const [xMin, xMax] = padDomain(Math.min(0,
|
|
894
|
-
const [yMin, yMax] = padDomain(0, Math.max(
|
|
912
|
+
const [xMin, xMax] = padDomain(Math.min(0, minData(xs)), Math.max(0, maxData(xs)));
|
|
913
|
+
const [yMin, yMax] = padDomain(0, Math.max(maxData(ys), -Math.log10(p_threshold)));
|
|
895
914
|
const { parts, plot } = frame(title);
|
|
896
915
|
const y = yAxis(parts, plot, yMin, yMax, y_label ?? '-log10 p');
|
|
897
916
|
const x = linearScale([xMin, xMax], [plot.x, plot.x + plot.width]);
|
|
@@ -936,7 +955,7 @@ export function renderVolcanoPlot({ title, x_label, y_label, points, fold_change
|
|
|
936
955
|
down_count: prepared.filter((point) => significant(point) && point.fold < 0).length,
|
|
937
956
|
fold_change_threshold,
|
|
938
957
|
p_threshold,
|
|
939
|
-
max_significance:
|
|
958
|
+
max_significance: maxData(ys),
|
|
940
959
|
labelled: [...labelled],
|
|
941
960
|
},
|
|
942
961
|
marks: prepared.length,
|
|
@@ -1000,8 +1019,8 @@ export function renderHeatmap({ title, x_label, y_label, rows, columns, matrix,
|
|
|
1000
1019
|
}
|
|
1001
1020
|
const flat = values.flat().filter((value) => value !== null);
|
|
1002
1021
|
if (flat.length === 0) throw new MolbioInputError('the matrix has no finite values to draw');
|
|
1003
|
-
const min =
|
|
1004
|
-
const max =
|
|
1022
|
+
const min = minData(flat);
|
|
1023
|
+
const max = maxData(flat);
|
|
1005
1024
|
const ramp = color_scale ?? (min < 0 ? 'divergingRedBlue' : 'viridis');
|
|
1006
1025
|
if (COLOR_RAMPS[ramp] === undefined) {
|
|
1007
1026
|
throw new MolbioInputError(`unknown color_scale ${JSON.stringify(color_scale)}; use ${Object.keys(COLOR_RAMPS).join(', ')}`);
|
package/cordis.patch.yml
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
#
|
|
22
22
|
# Keep the tool in the PRESET and only the anchor here. An earlier version of
|
|
23
23
|
# this file inserted a host row for the tool itself (`name: dsh-molbio-tools`),
|
|
24
|
-
# which both registered
|
|
24
|
+
# which both registered every tool globally and collided with the preset's own
|
|
25
25
|
# copy of that row:
|
|
26
26
|
#
|
|
27
27
|
# broken: "tool-molbio (...): never started"
|
package/docs/client-panel.md
CHANGED
|
@@ -243,7 +243,7 @@ dsh plugin --profile <profile> add dsh-molbio-tools # 或 npm 包
|
|
|
243
243
|
`/plugins/dsh-molbio-tools/client.js?rev=<hash>` → 页面刷新时按需拉取、物化、执行
|
|
244
244
|
`apply`。**不需要重建 Web 应用**(这正是客户端模块体系的设计目的)。
|
|
245
245
|
|
|
246
|
-
与 preset 渠道共存时:preset 提供
|
|
246
|
+
与 preset 渠道共存时:preset 提供 64 个工具,bundle 提供面板;同名工具由 preset 层
|
|
247
247
|
shadow(无冲突),面板的 `remote`/`slot` 服务不受影响。
|
|
248
248
|
|
|
249
249
|
## 7. 下一步
|
package/docs/history.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
| 包版本 | preset 版本 | 内容 | 工具数 |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
+
| 0.24.1 | — | **输入边界与数据安全加固**:BibTeX 不再覆盖文献库、相对读取按会话工作区、批量/glob/并发声明修复、20 万行输入不再 `RangeError`;工具数不变 | 64 |
|
|
19
20
|
| 0.24.0 | — | **数据卫生版**:新增 `molbio_contamination_screen`(内置载体/接头短签名,双链、0–2 错配)与 `molbio_fastq_trim`(质量/接头/引物/poly/N/长度,写 FASTQ/FASTA);修 FASTQ QC 接头表 Small RNA 5' 的 RNA→DNA 静默错 | 62 → 64 |
|
|
20
21
|
| 0.23.0 | — | **2.0 遗留修复 + 2.9a 调用卡 + 两个候选池工具**:跨内含子引物报告真实反向分子(F 在 R 上游)、logo/gRNA 调用卡、`molbio_protein_charge`、`molbio_dotplot`;同版完成 **DSH 0.2.2-rc.1 适配**(删掉上游已移除的 `list-agents` 子路径行、subagent 模型选择改用新键、守卫补上 specifier 解析) | 60 → 62 |
|
|
21
22
|
| 0.22.2 | — | **DSH 0.2.1-rc.5 适配**:基线 rc.3 → rc.5(上游 standard 逐字节未变);contract 声明守卫兼容桌面版裁掉 `lib/types` 的打包形态(+突变自检);engines.node 与 CI 降到 24 | 60 |
|
|
@@ -49,6 +50,29 @@
|
|
|
49
50
|
|
|
50
51
|
---
|
|
51
52
|
|
|
53
|
+
## v0.24.1(2026-11-08,包 0.24.1)
|
|
54
|
+
|
|
55
|
+
**三条教训,都是“合法输入 + 默认路径”才会暴露的错。**
|
|
56
|
+
|
|
57
|
+
**(1) “自定义文件名”不能等于“库文件本身”。** `molbio_paper_export_bibtex` 的默认输出是
|
|
58
|
+
`path.replace(/\.json$/i, '.bib')`;当用户传 `file: refs.txt` 时这个替换是 no-op,
|
|
59
|
+
于是导出的 BibTeX **直接覆盖文献库**。修法不是拒绝 `refs.txt`,而是先判断 `.json` 是否存在,
|
|
60
|
+
不存在就追加 `.bib`。教训:**默认值必须是“另一个文件”的证明**,不能依赖字符串替换恰好命中。
|
|
61
|
+
|
|
62
|
+
**(2) 用户规模数组不能 spread 进 `Math.min`/`Math.max`。** 图表接受 200,000 行、
|
|
63
|
+
FASTQ QC 接受约 200,000 条 reads,而 `Math.min(...values)` 在这些规模上会以
|
|
64
|
+
`RangeError` 失败;错误发生在参数个数上限,不是算法。现在所有可能跟随输入增长的极值
|
|
65
|
+
都用循环;固定的 64 项密码子表之类仍可 spread。教训:**先问数组的上界是谁定的**,再决定写法。
|
|
66
|
+
|
|
67
|
+
**(3) `safe` 声明要照着“默认写入”写,不是照着“参数是否为空”写。** 四个画图工具在
|
|
68
|
+
`output_path` 省略时仍会写默认 SVG,旧谓词却返回 `true`;并发调用会互相覆盖文件。
|
|
69
|
+
同样的问题在 `molbio_fasta_fastq extract` 带 `output_path` 时也存在。教训:**并发安全
|
|
70
|
+
是“这次调用碰不碰共享状态”,默认值也算碰。**
|
|
71
|
+
|
|
72
|
+
**顺带记录**:两个客户端产物因 `seqio.mjs` / `protein-structure.mjs` 改动而重建,
|
|
73
|
+
面板包按仓库规则 `0.1.9 → 0.1.10`;工具数保持 64,preset 行清单与 DSH 基线不变。
|
|
74
|
+
`benchmark-changed` 的 POSIX 路径断言在本地沙箱因 Node 无法 spawn `git` 未执行,CI 会执行。
|
|
75
|
+
|
|
52
76
|
## v0.24.0(2026-11-05,包 0.24.0)
|
|
53
77
|
|
|
54
78
|
**三条教训,都关于“表/边界看起来对、实际不对”。**
|
package/docs/maintainer.md
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
- **包**:`dsh-molbio-tools`,零依赖 DSH 插件包,**64 个 `molbio_*` 工具**(其中
|
|
34
34
|
`molbio_plot` 一个工具承载 8 种图表)。
|
|
35
|
-
- **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.24.
|
|
35
|
+
- **基线**:DSH **0.2.2-rc.1**;`package.json` 版本 0.24.1(DSH 桌面版内嵌 Node 24.21.0)。
|
|
36
36
|
- **两条分发渠道**:根包(工具 + 面板)与 `packages/molbio-panel`(只面板),
|
|
37
37
|
**两个独立的 npm 条目、两条独立的版本线**,必须分别发布。
|
|
38
38
|
- **工具只在专属模式里出现**(设计如此):bundle 的 `cordis.patch.yml` 只插一行**惰性宿主锚点**
|
package/docs/roadmap.md
CHANGED
|
@@ -10,7 +10,7 @@ L = 需要新机制或外部资源)。
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
## 1. 当前状态(0.
|
|
13
|
+
## 1. 当前状态(0.24.1)
|
|
14
14
|
|
|
15
15
|
- 64 个 `molbio_*` 工具,零依赖,随 preset 分发(见 [README](../README.md))。
|
|
16
16
|
- 离线测试金字塔 + 组合漂移守卫全绿;`benchmark/` 提供**模型使用**维度的评估
|
|
@@ -45,6 +45,10 @@ L = 需要新机制或外部资源)。
|
|
|
45
45
|
的 key 解析顺序变为 **`api_key` 参数 → DSH 凭据库 → 环境变量**(环境变量会被 agent 自己的 shell
|
|
46
46
|
子进程继承,凭据库不会);同时插件详情页出现 **PubMed API key** 表单,用户不必改 YAML。
|
|
47
47
|
没有凭据库的组合行为与 0.22.0 相同。
|
|
48
|
+
- **0.24.1 是输入边界与数据安全加固版(工具数不变)**:修掉 BibTeX 导出覆盖文献库、
|
|
49
|
+
相对路径按 provider cwd 解析、四个默认写 SVG 的工具被误标并发安全、批量 glob /
|
|
50
|
+
`max_files` / 坏路径、文献与协议库坏条目,以及 20 万行表格 / 13 万条 reads 触发的
|
|
51
|
+
`Math.min(...)` RangeError。面板包 `0.1.9 → 0.1.10`(客户端产物重建)。
|
|
48
52
|
- **0.24.0 是数据卫生版**:新增 survey #11 载体/接头污染筛查与 #12 FASTQ 修剪/过滤;
|
|
49
53
|
同时修掉 FASTQ QC 接头表里 Small RNA 5' 的 RNA→DNA 归一化静默错(旧表在 DNA reads 上
|
|
50
54
|
只能命中反向互补)。工具 62 → 64,benchmark 63 → 65;客户端产物仅因
|
package/docs/store-evidence.md
CHANGED
|
@@ -150,9 +150,9 @@ pnpm 的 hoisted linker 会在 `node_modules/` 留下一个指向仓库目录的
|
|
|
150
150
|
```
|
|
151
151
|
node test/contract.mjs → 17 checks passed(host 契约、client 产物、tarball 覆盖、声明回退的突变自检)
|
|
152
152
|
node test/preset-health.mjs → all 29 rows load on dsh 0.2.2-rc.1
|
|
153
|
-
node test/smoke.mjs → registered
|
|
153
|
+
node test/smoke.mjs → registered 64 tools, all smoke tests passed
|
|
154
154
|
node test/drift-probe.mjs → 16/16(15 个突变全部被抓到)
|
|
155
|
-
node benchmark/run.mjs --offline →
|
|
155
|
+
node benchmark/run.mjs --offline → 65 tasks checked against the shipped tools
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
`test/release.mjs` 在完整权限的本地会话里整套通过(包括末尾的 npm 版本探测);
|
package/fastq-qc.mjs
CHANGED
|
@@ -115,7 +115,10 @@ function entropyOf(counts) {
|
|
|
115
115
|
* that tapers is not mistaken for a quality drop.
|
|
116
116
|
*/
|
|
117
117
|
function perPosition(entries, maxBases) {
|
|
118
|
-
|
|
118
|
+
// A loop, not Math.max(...lengths): a 200k-read run is within the supported
|
|
119
|
+
// cap and would otherwise overflow the argument limit with a RangeError.
|
|
120
|
+
let longest = 0;
|
|
121
|
+
for (const entry of entries) if (entry.sequence.length > longest) longest = entry.sequence.length;
|
|
119
122
|
const length = Math.min(longest, maxBases);
|
|
120
123
|
const rows = [];
|
|
121
124
|
for (let position = 0; position < length; position++) {
|
|
@@ -411,9 +414,24 @@ export function fastqQcReport(entries, options = {}) {
|
|
|
411
414
|
}
|
|
412
415
|
|
|
413
416
|
const perBase = perPosition(selected, settings.max_plot_bases);
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
+
// One streaming pass over the qualities. The previous version materialised
|
|
418
|
+
// every Phred score of every sampled read in one array (200k reads x 150 bp
|
|
419
|
+
// is 30 million numbers) and then filtered it twice; the counters need none
|
|
420
|
+
// of that memory, and the statistics are unchanged.
|
|
421
|
+
let qualitySum = 0;
|
|
422
|
+
let qualityCount = 0;
|
|
423
|
+
let q20 = 0;
|
|
424
|
+
let q30 = 0;
|
|
425
|
+
for (const entry of selected) {
|
|
426
|
+
for (const character of entry.quality) {
|
|
427
|
+
const value = phredOf(character);
|
|
428
|
+
qualitySum += value;
|
|
429
|
+
qualityCount++;
|
|
430
|
+
if (value >= 20) q20++;
|
|
431
|
+
if (value >= 30) q30++;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (qualityCount === 0) throw new MolbioInputError('the FASTQ reads contain no quality values');
|
|
417
435
|
const gcCount = selected.reduce((sum, entry) => {
|
|
418
436
|
let gc = 0;
|
|
419
437
|
for (const base of entry.sequence) if (base === 'G' || base === 'C') gc++;
|
|
@@ -441,9 +459,9 @@ export function fastqQcReport(entries, options = {}) {
|
|
|
441
459
|
gc_percent: Math.round(gcFraction * 10000) / 100,
|
|
442
460
|
n_bases: nCount,
|
|
443
461
|
n_percent: Math.round((nCount / allBases) * 10000) / 100,
|
|
444
|
-
quality_mean: Math.round(
|
|
445
|
-
quality_q20_percent: Math.round((q20 /
|
|
446
|
-
quality_q30_percent: Math.round((q30 /
|
|
462
|
+
quality_mean: Math.round((qualitySum / qualityCount) * 100) / 100,
|
|
463
|
+
quality_q20_percent: Math.round((q20 / qualityCount) * 10000) / 100,
|
|
464
|
+
quality_q30_percent: Math.round((q30 / qualityCount) * 10000) / 100,
|
|
447
465
|
quality_tail_below_30: tailPosition(perBase, 30),
|
|
448
466
|
quality_tail_below_20: tailPosition(perBase, 20),
|
|
449
467
|
per_base: perBase,
|
package/host.mjs
CHANGED
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
*
|
|
28
28
|
* ── WHY NOT JUST ANCHOR ON index.mjs ───────────────────────────────────────
|
|
29
29
|
*
|
|
30
|
-
* `index.mjs` is the TOOL plugin: importing it must register
|
|
31
|
-
* prompt section. A host row naming it would put every tool back into every
|
|
30
|
+
* `index.mjs` is the TOOL plugin: importing it must register all 64 tools and
|
|
31
|
+
* a prompt section. A host row naming it would put every tool back into every
|
|
32
32
|
* session, which is the thing the preset split exists to avoid. This anchor is
|
|
33
33
|
* deliberately inert.
|
|
34
34
|
*
|
package/index.mjs
CHANGED
|
@@ -2211,7 +2211,14 @@ function fsService(ctx) {
|
|
|
2211
2211
|
|
|
2212
2212
|
async function readFileBytes(ctx, exec, path) {
|
|
2213
2213
|
const fs = fsService(ctx);
|
|
2214
|
-
|
|
2214
|
+
// Resolve a model-supplied relative name against the SESSION workspace, not
|
|
2215
|
+
// the provider's process cwd. `ctx.fs.resolve(path)` alone uses the backend
|
|
2216
|
+
// default (process.cwd()), which is why tool-fs asks `workingDirectory` for
|
|
2217
|
+
// the agent's cwd; the sandbox policy carries the same root here. Absolute
|
|
2218
|
+
// paths are still accepted unchanged (workspaceFilePath leaves them alone).
|
|
2219
|
+
const sandboxPolicyService = ctx.get('sandboxPolicy');
|
|
2220
|
+
const policy = sandboxPolicyService?.resolve({ ...exec?.agent !== undefined ? { session: exec.agent.session } : {} });
|
|
2221
|
+
const target = await fs.resolve(workspaceFilePath(path, exec, policy?.workspaceRoot), { signal: exec?.signal });
|
|
2215
2222
|
return await fs.readBytes(target, exec?.signal, MAX_DNA_BYTES);
|
|
2216
2223
|
}
|
|
2217
2224
|
|
|
@@ -3547,6 +3554,8 @@ const proteinPropsTool = define({
|
|
|
3547
3554
|
});
|
|
3548
3555
|
|
|
3549
3556
|
const proteinChargeTool = (ctx) => define({
|
|
3557
|
+
// Always writes protein-charge.svg and may open a viewer.
|
|
3558
|
+
safe: false,
|
|
3550
3559
|
name: 'molbio_protein_charge',
|
|
3551
3560
|
description: 'Plot a protein\'s net charge across a pH range and report the isoelectric point on the same curve (Henderson-Hasselbalch summation over the terminal groups, K/R/H positive and D/E/C/Y negative). pka_set selects the pK table: bjellqvist (the set molbio_protein_props uses, so the pI matches) or emboss (the Epk.dat values). The SVG is written to the workspace and opened with the OS default viewer (auto_view, default true); the sampled curve and the charge at pH 7.4 are returned too. pI is an estimate and is only meaningful together with the named pK set.',
|
|
3552
3561
|
parameters: {
|
|
@@ -3620,6 +3629,8 @@ const proteinChargeTool = (ctx) => define({
|
|
|
3620
3629
|
});
|
|
3621
3630
|
|
|
3622
3631
|
const dotplotTool = (ctx) => define({
|
|
3632
|
+
// Always writes dotplot.svg and may open a viewer.
|
|
3633
|
+
safe: false,
|
|
3623
3634
|
name: 'molbio_dotplot',
|
|
3624
3635
|
description: 'Compute and draw a self-comparison dotplot of a DNA sequence by exact word matching: a black cell at (i,j) means the k-mer at i equals the k-mer at j (direct repeat); a red cell means it equals the REVERSE COMPLEMENT of the k-mer at j (inverted repeat). word defaults to 8 (4-12); lower words find shorter repeats and produce more dots. The plot is binned to a fixed grid so a repetitive sequence cannot produce an enormous file; direct/inverted match counts and any scan/plot caps are reported. The SVG is written and opened with the OS default viewer (auto_view, default true). Exact k-mer view — not a windowed identity/scoring dotplot.',
|
|
3625
3636
|
parameters: {
|
|
@@ -3803,7 +3814,9 @@ const WHEEL_RESIDUE_SCHEMA = {
|
|
|
3803
3814
|
};
|
|
3804
3815
|
|
|
3805
3816
|
const helicalWheelTool = (ctx) => define({
|
|
3806
|
-
|
|
3817
|
+
// It always writes the SVG (output_path only chooses the name), so it is
|
|
3818
|
+
// never concurrency-safe; the old predicate marked the common case safe.
|
|
3819
|
+
safe: false,
|
|
3807
3820
|
name: 'molbio_helical_wheel',
|
|
3808
3821
|
description: 'Draw the helical wheel (Schiffer-Edmundson projection) of a peptide or protein stretch and analyse it: residues are placed 100° apart on the circle (3.6 residues per turn of an ideal alpha-helix), coloured by residue class (hydrophobic/polar/acidic/basic), with the Eisenberg hydrophobic moment μH of the stretch and the maximum μH over every window of moment_window residues (11 by default). An amphipathic helix shows one hydrophobic face and one polar face. The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); all values are sequence-propensity estimates from published scales, not a structure prediction.',
|
|
3809
3822
|
parameters: {
|
|
@@ -3922,7 +3935,8 @@ const HYDROPATHY_PEAK_SCHEMA = {
|
|
|
3922
3935
|
};
|
|
3923
3936
|
|
|
3924
3937
|
const hydropathyTool = (ctx) => define({
|
|
3925
|
-
|
|
3938
|
+
// Always writes the SVG; see the helical-wheel note.
|
|
3939
|
+
safe: false,
|
|
3926
3940
|
name: 'molbio_hydropathy_plot',
|
|
3927
3941
|
description: 'Plot the Kyte-Doolittle hydropathy profile of a protein: a sliding-window average (window, default 9; use 19-21 for transmembrane spans), the GRAVY value, and every peak whose window average reaches the threshold (default 1.6, the classic membrane-spanning cutoff). The tool WRITES an SVG file and opens it with the OS default viewer (auto_view, default true); the per-residue profile is also returned so it can feed further analysis. Values are the Kyte-Doolittle propensity scale, not a topology prediction.',
|
|
3928
3942
|
parameters: {
|
|
@@ -5484,7 +5498,10 @@ function validateGrnaOptions(args) {
|
|
|
5484
5498
|
}
|
|
5485
5499
|
|
|
5486
5500
|
const fastaFastqTool = (ctx) => define({
|
|
5487
|
-
|
|
5501
|
+
// convert always writes; extract writes only when output_path is given, so
|
|
5502
|
+
// stats/qc (and extract without a path) are the pure cases.
|
|
5503
|
+
safe: (args) => !(args.action === 'convert'
|
|
5504
|
+
|| (args.action === 'extract' && args.output_path !== undefined && args.output_path !== '')),
|
|
5488
5505
|
name: 'molbio_fasta_fastq',
|
|
5489
5506
|
description: 'Work with FASTA/FASTQ files in the workspace. action=stats: per-entry and overall statistics (length/GC). action=extract: pull entries by id (exact or substring) and optionally write them to a FASTA file. action=convert: FASTQ → FASTA (output_path required). action=qc: FASTQ quality statistics (mean/min/max Phred, low-quality fraction, per-position means).',
|
|
5490
5507
|
parameters: {
|
|
@@ -5652,13 +5669,22 @@ async function batchFileList(ctx, exec, args, policy, root) {
|
|
|
5652
5669
|
const files = [];
|
|
5653
5670
|
let truncated = false;
|
|
5654
5671
|
if (Array.isArray(args.paths) && args.paths.length > 0) {
|
|
5655
|
-
|
|
5672
|
+
// The directory walk enforces max_files; the explicit list must too, or the
|
|
5673
|
+
// documented 1-500 bound disappears for the easier input form.
|
|
5674
|
+
const maxFiles = Math.min(args.max_files ?? 50, BATCH_LIMITS.maxFiles);
|
|
5675
|
+
for (const supplied of args.paths.slice(0, maxFiles)) {
|
|
5656
5676
|
const absolute = workspaceFilePath(supplied, exec, root);
|
|
5657
|
-
const target = await fs.resolve(absolute);
|
|
5658
5677
|
const label = relative(root, absolute).split(sep).join('/');
|
|
5659
|
-
|
|
5678
|
+
try {
|
|
5679
|
+
const target = await fs.resolve(absolute);
|
|
5680
|
+
files.push({ target, label });
|
|
5681
|
+
} catch (error) {
|
|
5682
|
+
// A bad explicit path is one error ROW, not a failed batch: the same
|
|
5683
|
+
// contract the directory walk provides for files it cannot parse.
|
|
5684
|
+
files.push({ label, resolveError: error instanceof Error ? error.message : String(error) });
|
|
5685
|
+
}
|
|
5660
5686
|
}
|
|
5661
|
-
return { fs, files, truncated, maxBytes };
|
|
5687
|
+
return { fs, files, truncated: args.paths.length > files.length, maxBytes };
|
|
5662
5688
|
}
|
|
5663
5689
|
if (typeof fs.listDir !== 'function') {
|
|
5664
5690
|
throw new MolbioInputError('this harness fs service has no listDir; pass an explicit paths array instead of a directory');
|
|
@@ -5818,6 +5844,10 @@ const batchAnalyzeTool = (ctx) => define({
|
|
|
5818
5844
|
const rows = [];
|
|
5819
5845
|
for (const file of files) {
|
|
5820
5846
|
const format = detectBatchFormat(file.label, '');
|
|
5847
|
+
if (file.resolveError !== undefined) {
|
|
5848
|
+
rows.push(batchErrorRow(file.label, format, file.resolveError));
|
|
5849
|
+
continue;
|
|
5850
|
+
}
|
|
5821
5851
|
try {
|
|
5822
5852
|
const info = file.size === undefined ? await fs.stat(file.target) : { size: file.size };
|
|
5823
5853
|
if (info?.size !== undefined && info.size > maxBytes) {
|
|
@@ -7441,7 +7471,14 @@ const pubmedAbstractTool = (ctx) => define({
|
|
|
7441
7471
|
}
|
|
7442
7472
|
const apiKey = await pubmedKey(args, ctx);
|
|
7443
7473
|
const url = pubmedFetchUrl(pmids, apiKey);
|
|
7444
|
-
|
|
7474
|
+
let result;
|
|
7475
|
+
try {
|
|
7476
|
+
result = await web.fetch({ url }, exec?.signal);
|
|
7477
|
+
} catch (error) {
|
|
7478
|
+
// Same contract as eutilsRequest: a transport failure is a tool-input
|
|
7479
|
+
// error the caller can report, not a raw exception escaping the tool.
|
|
7480
|
+
throw new MolbioInputError(`PubMed efetch could not be reached: ${error instanceof Error ? error.message : String(error)}`);
|
|
7481
|
+
}
|
|
7445
7482
|
const body = result?.body?.content;
|
|
7446
7483
|
if (typeof body !== 'string' || body === '') {
|
|
7447
7484
|
throw new MolbioInputError(`efetch returned no content (status ${result?.statusCode ?? 'unknown'})`);
|
|
@@ -7501,9 +7538,13 @@ const bibtexTool = (ctx) => define({
|
|
|
7501
7538
|
const { fs, path, policy } = paperServiceDeps(ctx, exec, args);
|
|
7502
7539
|
const library = await loadLibrary(fs, path);
|
|
7503
7540
|
const bibtex = toBibtex(library.papers, args.tag_filter);
|
|
7541
|
+
// Never fall back to the LIBRARY path itself: a custom `file` without a
|
|
7542
|
+
// .json suffix used to make this replace a no-op, so the export overwrote
|
|
7543
|
+
// the library with BibTeX. Always derive a distinct .bib name.
|
|
7544
|
+
const defaultOutput = /\.json$/i.test(path) ? path.replace(/\.json$/i, '.bib') : `${path}.bib`;
|
|
7504
7545
|
const output = args.output_path !== undefined && args.output_path !== ''
|
|
7505
7546
|
? workspaceFilePath(args.output_path, exec, policy?.workspaceRoot)
|
|
7506
|
-
:
|
|
7547
|
+
: defaultOutput;
|
|
7507
7548
|
await writeWorkspaceFile(fs, output, bibtex, policy);
|
|
7508
7549
|
return { output_path: output, count: bibtex.trim() === '' ? 0 : library.papers.filter((paper) => args.tag_filter === undefined || args.tag_filter.length === 0 || (paper.tags !== undefined && args.tag_filter.some((tag) => paper.tags.includes(tag)))).length };
|
|
7509
7550
|
},
|
package/lib/client.js
CHANGED
|
@@ -4976,10 +4976,23 @@ window.__ModuleLoader__.load({
|
|
|
4976
4976
|
|
|
4977
4977
|
/** Summary statistics over parsed entries. */
|
|
4978
4978
|
function entryStats(entries, qualityMode = false) {
|
|
4979
|
-
|
|
4979
|
+
// Count in a loop instead of spreading into Math.min/Math.max: a large FASTQ
|
|
4980
|
+
// can hold hundreds of thousands of reads (and millions of quality
|
|
4981
|
+
// characters), and spreading such an array overflows the engine's argument
|
|
4982
|
+
// limit with a RangeError. The numbers are identical; only the failure mode
|
|
4983
|
+
// changes.
|
|
4984
|
+
let totalBases = 0;
|
|
4985
|
+
let minLength = entries.length === 0 ? 0 : Infinity;
|
|
4986
|
+
let maxLength = 0;
|
|
4987
|
+
let longest = 0;
|
|
4980
4988
|
let gc = 0;
|
|
4981
4989
|
let at = 0;
|
|
4982
4990
|
for (const entry of entries) {
|
|
4991
|
+
const length = entry.sequence.length;
|
|
4992
|
+
totalBases += length;
|
|
4993
|
+
if (length < minLength) minLength = length;
|
|
4994
|
+
if (length > maxLength) maxLength = length;
|
|
4995
|
+
if (length > longest) longest = length;
|
|
4983
4996
|
for (const base of entry.sequence) {
|
|
4984
4997
|
if (base === 'G' || base === 'C') gc++;
|
|
4985
4998
|
else if (base === 'A' || base === 'T') at++;
|
|
@@ -4987,22 +5000,34 @@ window.__ModuleLoader__.load({
|
|
|
4987
5000
|
}
|
|
4988
5001
|
const stats = {
|
|
4989
5002
|
entries: entries.length,
|
|
4990
|
-
total_bases:
|
|
4991
|
-
min_length:
|
|
4992
|
-
max_length:
|
|
4993
|
-
mean_length:
|
|
5003
|
+
total_bases: totalBases,
|
|
5004
|
+
min_length: minLength,
|
|
5005
|
+
max_length: maxLength,
|
|
5006
|
+
mean_length: entries.length === 0 ? 0 : Math.round((totalBases / entries.length) * 100) / 100,
|
|
4994
5007
|
gc_percent: gc + at === 0 ? 0 : Math.round((gc / (gc + at)) * 1000) / 10,
|
|
4995
5008
|
};
|
|
4996
5009
|
if (qualityMode) {
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5010
|
+
let qualitySum = 0;
|
|
5011
|
+
let qualityCount = 0;
|
|
5012
|
+
let lowCount = 0;
|
|
5013
|
+
let qualityMin = Infinity;
|
|
5014
|
+
let qualityMax = -Infinity;
|
|
5015
|
+
for (const entry of entries) {
|
|
5016
|
+
for (const ch of entry.quality) {
|
|
5017
|
+
const q = ch.charCodeAt(0) - 33;
|
|
5018
|
+
qualitySum += q;
|
|
5019
|
+
qualityCount++;
|
|
5020
|
+
if (q < 20) lowCount++;
|
|
5021
|
+
if (q < qualityMin) qualityMin = q;
|
|
5022
|
+
if (q > qualityMax) qualityMax = q;
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
5025
|
+
if (qualityCount > 0) {
|
|
5026
|
+
stats.quality_mean = Math.round((qualitySum / qualityCount) * 100) / 100;
|
|
5027
|
+
stats.quality_min = qualityMin;
|
|
5028
|
+
stats.quality_max = qualityMax;
|
|
5029
|
+
stats.low_quality_fraction = Math.round((lowCount / qualityCount) * 1000) / 1000;
|
|
5030
|
+
const maxLen = Math.min(150, longest);
|
|
5006
5031
|
const perPosition = [];
|
|
5007
5032
|
for (let pos = 0; pos < maxLen; pos++) {
|
|
5008
5033
|
const values = entries.filter((e) => e.sequence.length > pos).map((e) => e.quality.charCodeAt(pos) - 33);
|
|
@@ -7797,14 +7822,22 @@ window.__ModuleLoader__.load({
|
|
|
7797
7822
|
}
|
|
7798
7823
|
if (current !== null) peaks.push(current);
|
|
7799
7824
|
const total = points.reduce((sum, point) => sum + point.hydropathy, 0);
|
|
7825
|
+
// Loop rather than Math.min(...points): the protein length is not capped and
|
|
7826
|
+
// a long input would overflow the argument limit with a RangeError.
|
|
7827
|
+
let minimumHydropathy = Infinity;
|
|
7828
|
+
let maximumHydropathy = -Infinity;
|
|
7829
|
+
for (const point of points) {
|
|
7830
|
+
if (point.hydropathy < minimumHydropathy) minimumHydropathy = point.hydropathy;
|
|
7831
|
+
if (point.hydropathy > maximumHydropathy) maximumHydropathy = point.hydropathy;
|
|
7832
|
+
}
|
|
7800
7833
|
return {
|
|
7801
7834
|
length: sequence.length,
|
|
7802
7835
|
window,
|
|
7803
7836
|
threshold,
|
|
7804
7837
|
gravy: sequence.length === 0 ? 0 : round2([...sequence].reduce((sum, aa) => sum + KYTE_DOOLITTLE[aa], 0) / sequence.length),
|
|
7805
7838
|
mean_profile: round3(total / points.length),
|
|
7806
|
-
minimum_hydropathy: round3(
|
|
7807
|
-
maximum_hydropathy: round3(
|
|
7839
|
+
minimum_hydropathy: round3(minimumHydropathy),
|
|
7840
|
+
maximum_hydropathy: round3(maximumHydropathy),
|
|
7808
7841
|
peaks: peaks.map((peak) => ({ ...peak, maximum: round3(peak.maximum), length: peak.end - peak.start + 1 })),
|
|
7809
7842
|
points,
|
|
7810
7843
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-molbio-tools",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel, hydropathy and charge-pH plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, k-mer dotplots, read-level FASTQ QC, vector/adapter contamination screening, FASTQ trimming/filtering, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.mjs",
|
package/papers.mjs
CHANGED
|
@@ -51,6 +51,12 @@ export async function loadLibrary(fs, path) {
|
|
|
51
51
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.papers)) {
|
|
52
52
|
throw new Error('expected an object with a "papers" array');
|
|
53
53
|
}
|
|
54
|
+
// A hand-edited library can contain a null or scalar entry. Without this
|
|
55
|
+
// check, paperKey()/render() would throw a raw TypeError; a clear input
|
|
56
|
+
// error names the file instead.
|
|
57
|
+
if (!parsed.papers.every((paper) => typeof paper === 'object' && paper !== null && !Array.isArray(paper))) {
|
|
58
|
+
throw new Error('every entry in "papers" must be an object');
|
|
59
|
+
}
|
|
54
60
|
return parsed;
|
|
55
61
|
} catch (error) {
|
|
56
62
|
throw new MolbioInputError(`the paper library at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -115,6 +121,10 @@ export function updatePaper(library, id, changes) {
|
|
|
115
121
|
if (Array.isArray(changes.tags)) {
|
|
116
122
|
paper.tags = changes.tags.filter((tag) => typeof tag === 'string').map((tag) => tag.trim()).filter((tag) => tag !== '');
|
|
117
123
|
}
|
|
124
|
+
// The id is DERIVED from pmid, then url, then title+year. Recompute it after
|
|
125
|
+
// an update: leaving the old title-based id in place means adding the renamed
|
|
126
|
+
// paper later is not recognised as a duplicate.
|
|
127
|
+
paper.id = paperKey(paper);
|
|
118
128
|
return paper;
|
|
119
129
|
}
|
|
120
130
|
|
package/plot.mjs
CHANGED
|
@@ -70,6 +70,20 @@ function validateNumbers(values, label) {
|
|
|
70
70
|
return values;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** Smallest datum, by loop: spreading a long series into Math.min overflows the argument limit. */
|
|
74
|
+
function minValue(values) {
|
|
75
|
+
let min = Infinity;
|
|
76
|
+
for (const value of values) if (value < min) min = value;
|
|
77
|
+
return min;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Largest datum, by loop (same reason as {@link minValue}). */
|
|
81
|
+
function maxValue(values) {
|
|
82
|
+
let max = -Infinity;
|
|
83
|
+
for (const value of values) if (value > max) max = value;
|
|
84
|
+
return max;
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
function frameParts(title, xLabel, yLabel) {
|
|
74
88
|
const parts = [];
|
|
75
89
|
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="${FIGURE_FONT}" role="img">`);
|
|
@@ -110,11 +124,13 @@ export function renderBarChart({ title, x_label, y_label, labels, values, errors
|
|
|
110
124
|
}
|
|
111
125
|
errs = errors;
|
|
112
126
|
}
|
|
113
|
-
let min = Math.min(0,
|
|
114
|
-
let max = Math.max(0,
|
|
127
|
+
let min = Math.min(0, minValue(vals));
|
|
128
|
+
let max = Math.max(0, maxValue(vals));
|
|
115
129
|
if (errs !== undefined) {
|
|
116
|
-
|
|
117
|
-
|
|
130
|
+
for (let index = 0; index < vals.length; index++) {
|
|
131
|
+
min = Math.min(min, vals[index] - errs[index]);
|
|
132
|
+
max = Math.max(max, vals[index] + errs[index]);
|
|
133
|
+
}
|
|
118
134
|
}
|
|
119
135
|
if (max === min) max = min + 1;
|
|
120
136
|
const parts = frameParts(title, x_label, y_label);
|
|
@@ -158,15 +174,15 @@ export function renderScatterChart({ title, x_label, y_label, x, y, fit }) {
|
|
|
158
174
|
}
|
|
159
175
|
fitLine = fit;
|
|
160
176
|
}
|
|
161
|
-
let xMin =
|
|
162
|
-
let xMax =
|
|
177
|
+
let xMin = minValue(xs);
|
|
178
|
+
let xMax = maxValue(xs);
|
|
163
179
|
if (xMax === xMin) {
|
|
164
180
|
const pad = Math.abs(xMax) * 0.1 + 1;
|
|
165
181
|
xMin -= pad;
|
|
166
182
|
xMax += pad;
|
|
167
183
|
}
|
|
168
|
-
let yMin =
|
|
169
|
-
let yMax =
|
|
184
|
+
let yMin = minValue(ys);
|
|
185
|
+
let yMax = maxValue(ys);
|
|
170
186
|
if (fitLine !== undefined) {
|
|
171
187
|
yMin = Math.min(yMin, fitLine.intercept + fitLine.slope * xMin, fitLine.intercept + fitLine.slope * xMax);
|
|
172
188
|
yMax = Math.max(yMax, fitLine.intercept + fitLine.slope * xMin, fitLine.intercept + fitLine.slope * xMax);
|
package/preset/install.mjs
CHANGED
|
@@ -55,6 +55,14 @@ function flag(name) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
const profile = flag('profile') === undefined ? 'web' : String(flag('profile'));
|
|
58
|
+
// DSH profile names are directory names under <dshHome>/profiles. Reject
|
|
59
|
+
// separators/control characters before the value can reach a child process:
|
|
60
|
+
// on Windows this script runs through a shell, where an unquoted metacharacter
|
|
61
|
+
// (for example "web & calc") would be a command injection.
|
|
62
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile) || profile === '.' || profile === '..') {
|
|
63
|
+
console.error(`invalid --profile ${JSON.stringify(profile)}: start with a letter or digit, then letters, digits, ".", "_" or "-"`);
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
58
66
|
const checkOnly = flag('check') === true;
|
|
59
67
|
const dshHomeFlag = flag('dsh-home');
|
|
60
68
|
const dshHome = resolve(
|
package/protein-structure.mjs
CHANGED
|
@@ -278,14 +278,22 @@ export function hydropathyProfile(rawSequence, options = {}) {
|
|
|
278
278
|
}
|
|
279
279
|
if (current !== null) peaks.push(current);
|
|
280
280
|
const total = points.reduce((sum, point) => sum + point.hydropathy, 0);
|
|
281
|
+
// Loop rather than Math.min(...points): the protein length is not capped and
|
|
282
|
+
// a long input would overflow the argument limit with a RangeError.
|
|
283
|
+
let minimumHydropathy = Infinity;
|
|
284
|
+
let maximumHydropathy = -Infinity;
|
|
285
|
+
for (const point of points) {
|
|
286
|
+
if (point.hydropathy < minimumHydropathy) minimumHydropathy = point.hydropathy;
|
|
287
|
+
if (point.hydropathy > maximumHydropathy) maximumHydropathy = point.hydropathy;
|
|
288
|
+
}
|
|
281
289
|
return {
|
|
282
290
|
length: sequence.length,
|
|
283
291
|
window,
|
|
284
292
|
threshold,
|
|
285
293
|
gravy: sequence.length === 0 ? 0 : round2([...sequence].reduce((sum, aa) => sum + KYTE_DOOLITTLE[aa], 0) / sequence.length),
|
|
286
294
|
mean_profile: round3(total / points.length),
|
|
287
|
-
minimum_hydropathy: round3(
|
|
288
|
-
maximum_hydropathy: round3(
|
|
295
|
+
minimum_hydropathy: round3(minimumHydropathy),
|
|
296
|
+
maximum_hydropathy: round3(maximumHydropathy),
|
|
289
297
|
peaks: peaks.map((peak) => ({ ...peak, maximum: round3(peak.maximum), length: peak.end - peak.start + 1 })),
|
|
290
298
|
points,
|
|
291
299
|
};
|
package/records.mjs
CHANGED
|
@@ -27,6 +27,11 @@ export async function loadRecords(fs, path, key) {
|
|
|
27
27
|
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed[key])) {
|
|
28
28
|
throw new Error(`expected an object with a "${key}" array`);
|
|
29
29
|
}
|
|
30
|
+
// See papers.mjs: a null/scalar entry would otherwise reach
|
|
31
|
+
// record.name.trim() (protocols) as a raw TypeError.
|
|
32
|
+
if (!parsed[key].every((record) => typeof record === 'object' && record !== null && !Array.isArray(record))) {
|
|
33
|
+
throw new Error(`every entry in "${key}" must be an object`);
|
|
34
|
+
}
|
|
30
35
|
return parsed;
|
|
31
36
|
} catch (error) {
|
|
32
37
|
throw new MolbioInputError(`${path} is not a valid record file: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -54,7 +59,7 @@ export function addProtocol(records, entry) {
|
|
|
54
59
|
if (typeof entry !== 'object' || entry === null || typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
55
60
|
throw new MolbioInputError('each protocol needs a non-empty "name"');
|
|
56
61
|
}
|
|
57
|
-
if (records.some((record) => record.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) {
|
|
62
|
+
if (records.some((record) => typeof record?.name === 'string' && record.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) {
|
|
58
63
|
throw new MolbioInputError(`a protocol named ${JSON.stringify(entry.name)} already exists`);
|
|
59
64
|
}
|
|
60
65
|
const protocol = {
|
|
@@ -78,7 +83,17 @@ export function addProtocol(records, entry) {
|
|
|
78
83
|
export function updateProtocol(records, id, changes) {
|
|
79
84
|
const protocol = records.find((record) => record.id === id);
|
|
80
85
|
if (protocol === undefined) return undefined;
|
|
81
|
-
if (typeof changes.name === 'string' && changes.name.trim() !== '')
|
|
86
|
+
if (typeof changes.name === 'string' && changes.name.trim() !== '') {
|
|
87
|
+
const nextName = changes.name.trim();
|
|
88
|
+
// addProtocol enforces unique names; update must too, or a rename can
|
|
89
|
+
// create a duplicate the next add refuses to explain.
|
|
90
|
+
if (records.some((record) => record !== protocol
|
|
91
|
+
&& typeof record?.name === 'string'
|
|
92
|
+
&& record.name.trim().toLowerCase() === nextName.toLowerCase())) {
|
|
93
|
+
throw new MolbioInputError(`a protocol named ${JSON.stringify(nextName)} already exists`);
|
|
94
|
+
}
|
|
95
|
+
protocol.name = nextName;
|
|
96
|
+
}
|
|
82
97
|
if (typeof changes.category === 'string' && changes.category.trim() !== '') protocol.category = changes.category.trim();
|
|
83
98
|
if (Array.isArray(changes.steps)) {
|
|
84
99
|
protocol.steps = changes.steps.filter((step) => typeof step === 'string').map((step) => step.trim()).filter((step) => step !== '');
|
package/seqio.mjs
CHANGED
|
@@ -96,10 +96,23 @@ export function toFastq(entries) {
|
|
|
96
96
|
|
|
97
97
|
/** Summary statistics over parsed entries. */
|
|
98
98
|
export function entryStats(entries, qualityMode = false) {
|
|
99
|
-
|
|
99
|
+
// Count in a loop instead of spreading into Math.min/Math.max: a large FASTQ
|
|
100
|
+
// can hold hundreds of thousands of reads (and millions of quality
|
|
101
|
+
// characters), and spreading such an array overflows the engine's argument
|
|
102
|
+
// limit with a RangeError. The numbers are identical; only the failure mode
|
|
103
|
+
// changes.
|
|
104
|
+
let totalBases = 0;
|
|
105
|
+
let minLength = entries.length === 0 ? 0 : Infinity;
|
|
106
|
+
let maxLength = 0;
|
|
107
|
+
let longest = 0;
|
|
100
108
|
let gc = 0;
|
|
101
109
|
let at = 0;
|
|
102
110
|
for (const entry of entries) {
|
|
111
|
+
const length = entry.sequence.length;
|
|
112
|
+
totalBases += length;
|
|
113
|
+
if (length < minLength) minLength = length;
|
|
114
|
+
if (length > maxLength) maxLength = length;
|
|
115
|
+
if (length > longest) longest = length;
|
|
103
116
|
for (const base of entry.sequence) {
|
|
104
117
|
if (base === 'G' || base === 'C') gc++;
|
|
105
118
|
else if (base === 'A' || base === 'T') at++;
|
|
@@ -107,22 +120,34 @@ export function entryStats(entries, qualityMode = false) {
|
|
|
107
120
|
}
|
|
108
121
|
const stats = {
|
|
109
122
|
entries: entries.length,
|
|
110
|
-
total_bases:
|
|
111
|
-
min_length:
|
|
112
|
-
max_length:
|
|
113
|
-
mean_length:
|
|
123
|
+
total_bases: totalBases,
|
|
124
|
+
min_length: minLength,
|
|
125
|
+
max_length: maxLength,
|
|
126
|
+
mean_length: entries.length === 0 ? 0 : Math.round((totalBases / entries.length) * 100) / 100,
|
|
114
127
|
gc_percent: gc + at === 0 ? 0 : Math.round((gc / (gc + at)) * 1000) / 10,
|
|
115
128
|
};
|
|
116
129
|
if (qualityMode) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
let qualitySum = 0;
|
|
131
|
+
let qualityCount = 0;
|
|
132
|
+
let lowCount = 0;
|
|
133
|
+
let qualityMin = Infinity;
|
|
134
|
+
let qualityMax = -Infinity;
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
for (const ch of entry.quality) {
|
|
137
|
+
const q = ch.charCodeAt(0) - 33;
|
|
138
|
+
qualitySum += q;
|
|
139
|
+
qualityCount++;
|
|
140
|
+
if (q < 20) lowCount++;
|
|
141
|
+
if (q < qualityMin) qualityMin = q;
|
|
142
|
+
if (q > qualityMax) qualityMax = q;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (qualityCount > 0) {
|
|
146
|
+
stats.quality_mean = Math.round((qualitySum / qualityCount) * 100) / 100;
|
|
147
|
+
stats.quality_min = qualityMin;
|
|
148
|
+
stats.quality_max = qualityMax;
|
|
149
|
+
stats.low_quality_fraction = Math.round((lowCount / qualityCount) * 1000) / 1000;
|
|
150
|
+
const maxLen = Math.min(150, longest);
|
|
126
151
|
const perPosition = [];
|
|
127
152
|
for (let pos = 0; pos < maxLen; pos++) {
|
|
128
153
|
const values = entries.filter((e) => e.sequence.length > pos).map((e) => e.quality.charCodeAt(pos) - 33);
|
package/test/charts.mjs
CHANGED
|
@@ -284,6 +284,17 @@ test('histogram binning honours an explicit count and degenerates safely', () =>
|
|
|
284
284
|
assert.ok(tiny.bin_count >= 1 && Number.isFinite(tiny.bin_width), 'a two-value sample still gets a usable bin');
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
test('a table at the row cap does not overflow the argument limit', () => {
|
|
288
|
+
// 130k exceeds the engine's argument limit for Math.min(...values); the
|
|
289
|
+
// chart helpers must use a loop. 130000 is below MAX_ROWS (200000), so the
|
|
290
|
+
// input is one the tool advertises as supported.
|
|
291
|
+
const values = Array.from({ length: 130000 }, (_, index) => index % 1000);
|
|
292
|
+
const shape = histogramBins(values, 10);
|
|
293
|
+
assert.equal(shape.bins.reduce((sum, bin) => sum + bin.count, 0), values.length);
|
|
294
|
+
assert.equal(shape.bins[0].start, 0);
|
|
295
|
+
assert.equal(shape.bins.at(-1).end, 999);
|
|
296
|
+
});
|
|
297
|
+
|
|
287
298
|
// ── CSV / TSV reader ────────────────────────────────────────────────────────
|
|
288
299
|
|
|
289
300
|
test('the reader detects the delimiter and keeps quoted fields intact', () => {
|
package/test/smoke.mjs
CHANGED
|
@@ -35,6 +35,8 @@ const plugin = await import('../index.mjs');
|
|
|
35
35
|
const lib = await import('../lib.mjs');
|
|
36
36
|
const view = await import('../view.mjs');
|
|
37
37
|
const snapgene = await import('../snapgene.mjs');
|
|
38
|
+
const { fastqQcReport } = await import('../fastq-qc.mjs');
|
|
39
|
+
const { entryStats } = await import('../seqio.mjs');
|
|
38
40
|
const { reverseComplement: reverseComplementOf } = lib;
|
|
39
41
|
|
|
40
42
|
// The auto-view opener would spawn real viewer processes on the machine
|
|
@@ -1461,6 +1463,33 @@ function makeTemplate(n, seed = 42) {
|
|
|
1461
1463
|
const qc = await run('molbio_fasta_fastq', { path: at('reads.fq'), action: 'qc' });
|
|
1462
1464
|
assert.equal(qc.stats.quality_mean, 39.5);
|
|
1463
1465
|
assert.equal(qc.stats.low_quality_fraction, 0);
|
|
1466
|
+
|
|
1467
|
+
// Large-input guards: these arrays are within the documented limits, and the
|
|
1468
|
+
// old Math.min(...)/Math.max(...) forms threw RangeError on them.
|
|
1469
|
+
const manyReads = Array.from({ length: 1000 }, (_, index) => ({
|
|
1470
|
+
id: `bulk-${index}`,
|
|
1471
|
+
description: '',
|
|
1472
|
+
sequence: 'ACGT'.repeat(37) + 'AC',
|
|
1473
|
+
quality: 'I'.repeat(150),
|
|
1474
|
+
}));
|
|
1475
|
+
const bulkStats = entryStats(manyReads, true);
|
|
1476
|
+
assert.equal(bulkStats.entries, 1000);
|
|
1477
|
+
assert.equal(bulkStats.quality_mean, 40);
|
|
1478
|
+
assert.equal(bulkStats.quality_per_position_mean.length, 150);
|
|
1479
|
+
|
|
1480
|
+
const bulkQc = fastqQcReport(Array.from({ length: 130000 }, (_, index) => ({
|
|
1481
|
+
id: `read-${index}`,
|
|
1482
|
+
sequence: 'ACGTACGTAC',
|
|
1483
|
+
quality: 'IIIIIIIIII',
|
|
1484
|
+
})));
|
|
1485
|
+
assert.equal(bulkQc.reads, 130000);
|
|
1486
|
+
assert.equal(bulkQc.quality_mean, 40);
|
|
1487
|
+
assert.equal(bulkQc.per_base.length, 10);
|
|
1488
|
+
|
|
1489
|
+
// A relative path is a SESSION-workspace path, not a provider-cwd path.
|
|
1490
|
+
memFs.files.set(shown('relative.fa'), '>rel1\nACGT\n');
|
|
1491
|
+
const relative = await run('molbio_fasta_fastq', { path: 'relative.fa', action: 'stats' });
|
|
1492
|
+
assert.equal(relative.stats.entries, 1);
|
|
1464
1493
|
}
|
|
1465
1494
|
|
|
1466
1495
|
{
|
|
@@ -1501,6 +1530,15 @@ function makeTemplate(n, seed = 42) {
|
|
|
1501
1530
|
const bib = memFs.files.get(at('papers.bib')).toString('utf8');
|
|
1502
1531
|
assert.ok(bib.includes('@article{pmid111,'));
|
|
1503
1532
|
assert.ok(bib.includes('title = {Alpha},'));
|
|
1533
|
+
|
|
1534
|
+
// A custom library name without .json must still get a DISTINCT .bib output,
|
|
1535
|
+
// not overwrite the library with BibTeX.
|
|
1536
|
+
await run('molbio_paper_add', { file: at('refs.txt'), papers: [{ title: 'Gamma' }] });
|
|
1537
|
+
const custom = await run('molbio_paper_export_bibtex', { file: at('refs.txt') });
|
|
1538
|
+
assert.equal(custom.output_path, at('refs.txt.bib'));
|
|
1539
|
+
const stillLibrary = await run('molbio_paper_list', { file: at('refs.txt') });
|
|
1540
|
+
assert.equal(stillLibrary.papers.length, 1);
|
|
1541
|
+
assert.equal(stillLibrary.papers[0].title, 'Gamma');
|
|
1504
1542
|
}
|
|
1505
1543
|
|
|
1506
1544
|
{
|
|
@@ -1530,6 +1568,13 @@ function makeTemplate(n, seed = 42) {
|
|
|
1530
1568
|
const experiments = await run('molbio_experiment_list', { file: at('experiments2.json') });
|
|
1531
1569
|
assert.equal(experiments.experiments[0].title, 'Clone #12 miniprep');
|
|
1532
1570
|
assert.equal(experiments.experiments[0].protocol_id, listed.protocols[0].id);
|
|
1571
|
+
|
|
1572
|
+
// Renaming onto an existing protocol is refused, matching addProtocol.
|
|
1573
|
+
const second = await run('molbio_protocol_add', { file: at('protocols2.json'), name: 'Midiprep' });
|
|
1574
|
+
await assert.rejects(
|
|
1575
|
+
() => run('molbio_protocol_update', { file: at('protocols2.json'), id: second.protocol.id, name: 'miniprep' }),
|
|
1576
|
+
/already exists/,
|
|
1577
|
+
);
|
|
1533
1578
|
}
|
|
1534
1579
|
|
|
1535
1580
|
// ── official-convention compliance ──────────────────────────────────────────
|
|
@@ -1556,6 +1601,15 @@ function makeTemplate(n, seed = 42) {
|
|
|
1556
1601
|
assert.equal(tool('molbio_clone_simulate').isConcurrencySafe({ save_path: at('x.fa') }), false);
|
|
1557
1602
|
assert.equal(tool('molbio_fasta_fastq').isConcurrencySafe({ action: 'stats' }), true);
|
|
1558
1603
|
assert.equal(tool('molbio_fasta_fastq').isConcurrencySafe({ action: 'convert' }), false);
|
|
1604
|
+
assert.equal(tool('molbio_fasta_fastq').isConcurrencySafe({ action: 'extract', output_path: at('x.fa') }), false);
|
|
1605
|
+
// Every tool that writes a DEFAULT svg file is unsafe even when output_path
|
|
1606
|
+
// is omitted; these four predicates used to claim the opposite.
|
|
1607
|
+
assert.equal(tool('molbio_helical_wheel').isConcurrencySafe({}), false);
|
|
1608
|
+
assert.equal(tool('molbio_hydropathy_plot').isConcurrencySafe({}), false);
|
|
1609
|
+
assert.equal(tool('molbio_protein_charge').isConcurrencySafe({}), false);
|
|
1610
|
+
assert.equal(tool('molbio_dotplot').isConcurrencySafe({}), false);
|
|
1611
|
+
assert.equal(tool('molbio_extract_region').isConcurrencySafe({ output_path: at('x.fa') }), false);
|
|
1612
|
+
assert.equal(tool('molbio_qpcr_efficiency').isConcurrencySafe({ plot_path: at('x.svg') }), false);
|
|
1559
1613
|
}
|
|
1560
1614
|
|
|
1561
1615
|
// ── IIS enzymes and cross-intron primer design ──────────────────────────────
|
|
@@ -2075,6 +2129,15 @@ delete process.env.MOLBIO_NCBI_API_KEY;
|
|
|
2075
2129
|
assert.equal(out.abstracts[0].error, '');
|
|
2076
2130
|
assert.equal(out.abstracts[0].title, 'Test Article Title');
|
|
2077
2131
|
assert.ok(mockWeb.calls.length === 1 && mockWeb.calls[0].includes('/efetch.fcgi') && mockWeb.calls[0].includes('api_key=test-key-123'));
|
|
2132
|
+
|
|
2133
|
+
// A transport failure is reported as a tool-input error, not a raw throw.
|
|
2134
|
+
const originalFetch = mockWeb.fetch;
|
|
2135
|
+
mockWeb.fetch = async () => { throw new Error('network down'); };
|
|
2136
|
+
try {
|
|
2137
|
+
await assert.rejects(() => run('molbio_pubmed_abstract', { pmids: ['12345678'], api_key: 'test-key' }), /could not be reached/);
|
|
2138
|
+
} finally {
|
|
2139
|
+
mockWeb.fetch = originalFetch;
|
|
2140
|
+
}
|
|
2078
2141
|
}
|
|
2079
2142
|
|
|
2080
2143
|
// Restore whatever the developer machine had exported.
|
|
@@ -2114,6 +2177,21 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
2114
2177
|
|
|
2115
2178
|
const finalList = await run('molbio_paper_list', { file });
|
|
2116
2179
|
assert.equal(finalList.papers.length, 1);
|
|
2180
|
+
|
|
2181
|
+
// An update that changes the title must re-derive a title-based id, or a
|
|
2182
|
+
// later add of the renamed paper would not be recognised as a duplicate.
|
|
2183
|
+
const renamedFile = at('paper-rename.json');
|
|
2184
|
+
await run('molbio_paper_add', { file: renamedFile, papers: [{ title: 'Old title', year: '2020' }] });
|
|
2185
|
+
const renamed = await run('molbio_paper_update', { file: renamedFile, id: 'title:Old title:2020', title: 'New title' });
|
|
2186
|
+
assert.equal(renamed.found, true);
|
|
2187
|
+
assert.equal(renamed.paper.id, 'title:New title:2020');
|
|
2188
|
+
|
|
2189
|
+
// A hand-edited library with a null entry is rejected with a named input
|
|
2190
|
+
// error, not a raw TypeError from paperKey()/render().
|
|
2191
|
+
memFs.files.set(at('bad-papers.json'), JSON.stringify({ papers: [null] }));
|
|
2192
|
+
await assert.rejects(() => run('molbio_paper_list', { file: at('bad-papers.json') }), /every entry in "papers" must be an object/);
|
|
2193
|
+
memFs.files.set(at('bad-protocols.json'), JSON.stringify({ protocols: [null] }));
|
|
2194
|
+
await assert.rejects(() => run('molbio_protocol_list', { file: at('bad-protocols.json') }), /every entry in "protocols" must be an object/);
|
|
2117
2195
|
}
|
|
2118
2196
|
|
|
2119
2197
|
// ── v13: reaction-condition knobs (salt / primer concentration) ─────────────
|
|
@@ -2740,6 +2818,11 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
2740
2818
|
assert.equal(goodDna.feature_count, 10);
|
|
2741
2819
|
assert.ok(batchDna.files.find((row) => row.path.endsWith('bad.dna')).error.length > 0);
|
|
2742
2820
|
|
|
2821
|
+
// A glob is a glob: the dot in "a.fa" is literal, not a regex wildcard.
|
|
2822
|
+
memFs.files.set(at('batch/aXfa'), '>x1\nACGT\n');
|
|
2823
|
+
const literal = await run('molbio_batch_analyze', { directory: 'batch', pattern: 'a.fa', output_path: at('literal.csv') });
|
|
2824
|
+
assert.deepEqual(literal.files.map((row) => row.path), ['batch/a.fa']);
|
|
2825
|
+
|
|
2743
2826
|
// Recursion is opt-in; the nested file appears only with recursive: true.
|
|
2744
2827
|
const flat = await run('molbio_batch_analyze', { directory: 'batch', pattern: '*.fa', output_path: at('flat.csv') });
|
|
2745
2828
|
assert.deepEqual(flat.files.map((row) => row.path), ['batch/a.fa', 'batch/b.fa']);
|
|
@@ -2750,6 +2833,13 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
2750
2833
|
const limited = await run('molbio_batch_analyze', { directory: 'batch', pattern: '*.fa', max_files: 1, output_path: at('limited.csv') });
|
|
2751
2834
|
assert.equal(limited.file_count, 1);
|
|
2752
2835
|
assert.equal(limited.truncated, true);
|
|
2836
|
+
const limitedPaths = await run('molbio_batch_analyze', {
|
|
2837
|
+
paths: [at('batch/a.fa'), at('batch/b.fa')],
|
|
2838
|
+
max_files: 1,
|
|
2839
|
+
output_path: at('limited-paths.csv'),
|
|
2840
|
+
});
|
|
2841
|
+
assert.equal(limitedPaths.file_count, 1, 'max_files also bounds an explicit paths list');
|
|
2842
|
+
assert.equal(limitedPaths.truncated, true);
|
|
2753
2843
|
|
|
2754
2844
|
await assert.rejects(() => run('molbio_batch_analyze', { directory: 'batch', analysis: 'enzymes' }), /non-empty enzymes array/);
|
|
2755
2845
|
await assert.rejects(() => run('molbio_batch_analyze', { directory: 'batch', analysis: 'enzymes', enzymes: ['NotAnEnzyme'] }), /unknown enzyme/);
|