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 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
- .replace(/[.+^$()|[\]{}]/g, '\$&')
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');
@@ -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 57 tools call bindings from more than one
23
- * module. So a per-file → tool map is not a partition, it is a graph, and the
24
- * honest model is:
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
@@ -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 57 tools is the "Molecular Biology Lab" agent
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 —
@@ -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 57 tools
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,
@@ -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 all 60 tools globally and collided with the preset's own",
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 62 tools and moves
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 = Math.min(...finite);
134
- const max = Math.max(...finite);
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, ...entry.stats.outliers, entry.stats.min));
638
- const highs = prepared.map((entry) => Math.max(entry.stats.whisker_high, ...entry.stats.outliers, entry.stats.max));
639
- const [domainMin, domainMax] = padDomain(Math.min(...lows), Math.max(...highs));
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(Math.min(...allX), Math.max(...allX));
699
- const [yMin, yMax] = padDomain(Math.min(...allY), Math.max(...allY));
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: Math.min(...entry.xs), x_max: Math.max(...entry.xs), y_min: Math.min(...entry.ys), y_max: Math.max(...entry.ys) })),
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: [Math.min(...allX), Math.max(...allX)],
726
- y_range: [Math.min(...allY), Math.max(...allY)],
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(Math.min(...lows), Math.max(...highs));
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, ...xs), Math.max(0, ...xs));
894
- const [yMin, yMax] = padDomain(0, Math.max(...ys, -Math.log10(p_threshold)));
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: Math.max(...ys),
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 = Math.min(...flat);
1004
- const max = Math.max(...flat);
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 all 60 tools globally and collided with the preset's own
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"
@@ -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 提供 57 个工具,bundle 提供面板;同名工具由 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
  **三条教训,都关于“表/边界看起来对、实际不对”。**
@@ -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.0(DSH 桌面版内嵌 Node 24.21.0)。
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.23.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;客户端产物仅因
@@ -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 62 tools, all smoke tests passed
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 → 63 tasks checked against the shipped tools
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
- const longest = Math.max(...entries.map((entry) => entry.sequence.length));
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
- const summaryQualities = selected.flatMap((entry) => [...entry.quality].map(phredOf));
415
- const q20 = summaryQualities.filter((value) => value >= 20).length;
416
- const q30 = summaryQualities.filter((value) => value >= 30).length;
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(mean(summaryQualities) * 100) / 100,
445
- quality_q20_percent: Math.round((q20 / summaryQualities.length) * 10000) / 100,
446
- quality_q30_percent: Math.round((q30 / summaryQualities.length) * 10000) / 100,
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 57 tools and a
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
- const target = await fs.resolve(path);
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
- safe: (args) => args.output_path === undefined || args.output_path === '',
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
- safe: (args) => args.output_path === undefined || args.output_path === '',
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
- safe: (args) => args.action !== 'convert' && args.action !== 'extract',
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
- for (const supplied of args.paths) {
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
- files.push({ target, label });
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
- const result = await web.fetch({ url }, exec?.signal);
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
- : path.replace(/\.json$/i, '.bib');
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
- const lengths = entries.map((entry) => entry.sequence.length);
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: lengths.reduce((sum, len) => sum + len, 0),
4991
- min_length: Math.min(...lengths),
4992
- max_length: Math.max(...lengths),
4993
- mean_length: Math.round((lengths.reduce((sum, len) => sum + len, 0) / lengths.length) * 100) / 100,
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
- const allQ = entries.flatMap((entry) => [...entry.quality].map((ch) => ch.charCodeAt(0) - 33));
4998
- if (allQ.length > 0) {
4999
- const mean = allQ.reduce((sum, q) => sum + q, 0) / allQ.length;
5000
- const low = allQ.filter((q) => q < 20).length / allQ.length;
5001
- stats.quality_mean = Math.round(mean * 100) / 100;
5002
- stats.quality_min = Math.min(...allQ);
5003
- stats.quality_max = Math.max(...allQ);
5004
- stats.low_quality_fraction = Math.round(low * 1000) / 1000;
5005
- const maxLen = Math.min(150, Math.max(...entries.map((e) => e.sequence.length)));
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(Math.min(...points.map((point) => point.hydropathy))),
7807
- maximum_hydropathy: round3(Math.max(...points.map((point) => point.hydropathy))),
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.0",
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, ...vals);
114
- let max = Math.max(0, ...vals);
127
+ let min = Math.min(0, minValue(vals));
128
+ let max = Math.max(0, maxValue(vals));
115
129
  if (errs !== undefined) {
116
- min = Math.min(min, ...vals.map((v, i) => v - errs[i]));
117
- max = Math.max(max, ...vals.map((v, i) => v + errs[i]));
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 = Math.min(...xs);
162
- let xMax = Math.max(...xs);
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 = Math.min(...ys);
169
- let yMax = Math.max(...ys);
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);
@@ -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(
@@ -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(Math.min(...points.map((point) => point.hydropathy))),
288
- maximum_hydropathy: round3(Math.max(...points.map((point) => point.hydropathy))),
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() !== '') protocol.name = 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
- const lengths = entries.map((entry) => entry.sequence.length);
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: lengths.reduce((sum, len) => sum + len, 0),
111
- min_length: Math.min(...lengths),
112
- max_length: Math.max(...lengths),
113
- mean_length: Math.round((lengths.reduce((sum, len) => sum + len, 0) / lengths.length) * 100) / 100,
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
- const allQ = entries.flatMap((entry) => [...entry.quality].map((ch) => ch.charCodeAt(0) - 33));
118
- if (allQ.length > 0) {
119
- const mean = allQ.reduce((sum, q) => sum + q, 0) / allQ.length;
120
- const low = allQ.filter((q) => q < 20).length / allQ.length;
121
- stats.quality_mean = Math.round(mean * 100) / 100;
122
- stats.quality_min = Math.min(...allQ);
123
- stats.quality_max = Math.max(...allQ);
124
- stats.low_quality_fraction = Math.round(low * 1000) / 1000;
125
- const maxLen = Math.min(150, Math.max(...entries.map((e) => e.sequence.length)));
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/);