@alotop/dsh-matlab-bridge 0.1.1 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,8 +206,8 @@ such as `disp('hi')`.
206
206
  ## Development
207
207
 
208
208
  ```sh
209
- git clone https://github.com/alotop/dsh-matlab-bridge.git
210
- cd dsh-matlab-bridge
209
+ git clone https://github.com/alotop/dsh-matlab.git
210
+ cd dsh-matlab
211
211
  npm run setup # lay out the engine runtime from your local MATLAB
212
212
  npm run selftest # end-to-end checks against a real MATLAB (starts one)
213
213
  npm run check # syntax-check the plugin and scripts
@@ -235,7 +235,7 @@ repository. One-time setup on npmjs.com:
235
235
 
236
236
  1. The package must exist — do a first manual `npm publish` from a checkout.
237
237
  2. Package → Settings → **Trusted Publisher** → GitHub Actions, with
238
- repository `alotop/dsh-matlab-bridge` and workflow `release.yml`.
238
+ repository `alotop/dsh-matlab` and workflow `release.yml`.
239
239
 
240
240
  After that, `id-token: write` in the workflow is the only credential needed, and
241
241
  every published version carries a signed provenance attestation.
package/README.zh-CN.md CHANGED
@@ -156,8 +156,8 @@ MATLAB Engine 会把 MATLAB 命令窗口输出转发到驱动的 stdout,裸 JS
156
156
  ## 开发
157
157
 
158
158
  ```sh
159
- git clone https://github.com/alotop/dsh-matlab-bridge.git
160
- cd dsh-matlab-bridge
159
+ git clone https://github.com/alotop/dsh-matlab.git
160
+ cd dsh-matlab
161
161
  npm run setup # 从本地 MATLAB 铺开引擎运行库
162
162
  npm run selftest # 对真实 MATLAB 的端到端检查(会启动一个)
163
163
  npm run check # 语法检查插件与脚本
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@alotop/dsh-matlab-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.6",
4
4
  "description": "Run and interactively debug MATLAB code from a DSH session, through a persistent MATLAB Engine session.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/alotop/dsh-matlab.git"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "provenance": true
12
+ },
5
13
  "keywords": [
6
14
  "dsh",
7
15
  "dsh-plugin",
@@ -36,23 +44,16 @@
36
44
  "scripts": {
37
45
  "setup": "node scripts/setup-engine.mjs",
38
46
  "selftest": "node scripts/run-selftest.mjs",
39
- "check": "node --check src/plugin.mjs && node --check scripts/setup-engine.mjs"
40
- },
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/alotop/dsh-matlab-bridge.git"
47
+ "check": "node --check src/plugin.mjs && node --check scripts/setup-engine.mjs && node --check scripts/run-selftest.mjs && node --check scripts/inspect-tarball.mjs",
48
+ "pack:check": "npm pack --dry-run --json > pack.json && node scripts/inspect-tarball.mjs"
44
49
  },
45
50
  "bugs": {
46
- "url": "https://github.com/alotop/dsh-matlab-bridge/issues"
51
+ "url": "https://github.com/alotop/dsh-matlab/issues"
47
52
  },
48
- "homepage": "https://github.com/alotop/dsh-matlab-bridge#readme",
53
+ "homepage": "https://github.com/alotop/dsh-matlab#readme",
49
54
  "dsh": {
50
55
  "bundle": {
51
56
  "patch": "./cordis.patch.yml"
52
57
  }
53
- },
54
- "publishConfig": {
55
- "access": "public",
56
- "provenance": true
57
58
  }
58
59
  }
@@ -70,6 +70,14 @@ _STDOUT = sys.stdout.buffer
70
70
 
71
71
  PROTOCOL_PREFIX = "@@DSH:"
72
72
 
73
+ # Every action op_debug implements. Kept here so an unknown action can be
74
+ # rejected before the engine is touched -- see op_debug.
75
+ DEBUG_ACTIONS = (
76
+ "break", "breakError", "clearBreaks", "run", "status", "stack",
77
+ "vars", "get", "eval", "step", "stepIn", "stepOut", "continue",
78
+ "quit", "finish",
79
+ )
80
+
73
81
  _engine = None
74
82
  _future = None
75
83
  _last_run_error = None
@@ -172,6 +180,11 @@ def op_eval(req):
172
180
  def op_debug(req):
173
181
  global _future, _last_run_error
174
182
  action = req.get("action", "")
183
+ # Validate before touching the engine: a typo must not pay a ~20s MATLAB
184
+ # launch, which is otherwise a very expensive way to learn you misspelled
185
+ # an action.
186
+ if action not in DEBUG_ACTIONS:
187
+ return {"ok": False, "err": "unknown debug action: %s" % action}
175
188
  eng = engine()
176
189
 
177
190
  if action == "break":
@@ -277,18 +290,23 @@ def op_debug(req):
277
290
 
278
291
 
279
292
  def _format_value(value):
280
- """Render a MATLAB value for the model without dumping it as Python repr."""
293
+ """Render a MATLAB value as text for the model.
294
+
295
+ Formatting happens in Python because that is where the value already lives.
296
+ Calling back into MATLAB with `evalc('disp(value)')` would name a MATLAB
297
+ variable `value` that does not exist, so it merely leaked a spurious
298
+ "unrecognized function or variable 'value'" onto stderr beside a value that
299
+ was on its way out anyway. Engine array wrappers stringify to a readable
300
+ nested list, which is a fine rendering for a debugger readout.
301
+ """
281
302
  if value is None:
282
303
  return ""
304
+ if isinstance(value, bool):
305
+ return "true" if value else "false"
283
306
  if isinstance(value, float):
284
- return repr(value)
285
- if isinstance(value, (int, bool, str)):
286
- return str(value)
287
- try:
288
- text = engine().eval("evalc('disp(value)')", nargout=1)
289
- return text or str(value)
290
- except Exception: # noqa: BLE001
291
- return str(value)
307
+ # MATLAB prints an integral double without a trailing ".0".
308
+ return str(int(value)) if value.is_integer() else repr(value)
309
+ return str(value)
292
310
 
293
311
 
294
312
  def _load_json(payload, label):
@@ -157,9 +157,8 @@ def run_figure_checks(driver, out_dir):
157
157
 
158
158
  resp = driver.call("figure", action="list")
159
159
  check("no figures remain after close", resp.get("ok") is True and resp.get("figures") == [], repr(resp))
160
-
161
- resp = driver.call("figure", action="nonsense")
162
- check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
160
+ # Rejecting an unknown action is asserted in main(), before MATLAB starts,
161
+ # so that the same check also proves a typo does not launch the engine.
163
162
 
164
163
 
165
164
  def run_debug_checks(driver, fixture_dir):
@@ -179,7 +178,7 @@ def run_debug_checks(driver, fixture_dir):
179
178
  check("paused frame exposes locals", "a" in out and "b" in out, repr(out))
180
179
 
181
180
  resp = driver.call("debug", action="get", name="a")
182
- check("reads a local by name", resp.get("ok") is True and resp.get("out", "").startswith("1"), repr(resp))
181
+ check("reads a local by name", resp.get("ok") is True and resp.get("out", "").strip() == "1", repr(resp))
183
182
 
184
183
  resp = driver.call("debug", action="get", name="c")
185
184
  check("unassigned local is reported as an error", resp.get("ok") is False, repr(resp))
@@ -188,10 +187,20 @@ def run_debug_checks(driver, fixture_dir):
188
187
  check("step keeps the session paused", resp.get("state") == "paused", repr(resp.get("state")))
189
188
 
190
189
  resp = driver.call("debug", action="get", name="c")
191
- check("stepping makes the next local visible", resp.get("ok") is True and resp.get("out", "").startswith("3"), repr(resp))
190
+ check("stepping makes the next local visible", resp.get("ok") is True and resp.get("out", "").strip() == "3", repr(resp))
192
191
 
193
192
  resp = driver.call("debug", action="eval", code="a + b")
194
- check("evaluates an expression in the paused frame", resp.get("ok") is True and resp.get("out", "").startswith("3"), repr(resp))
193
+ check("evaluates an expression in the paused frame", resp.get("ok") is True and resp.get("out", "").strip() == "3", repr(resp))
194
+
195
+ # Regression: _format_value once called back into MATLAB with an expression
196
+ # naming `value`, which is a PYTHON variable -- MATLAB has no such name, so
197
+ # every non-scalar readout leaked an error onto stderr beside its value.
198
+ before = len(driver.chatter)
199
+ resp = driver.call("debug", action="eval", code="[a b]")
200
+ leaked = driver.chatter[before:]
201
+ check("evaluating a vector in the paused frame succeeds", resp.get("ok") is True, repr(resp))
202
+ check("a frame value readout leaks no MATLAB error to stderr",
203
+ not any("value" in line for line in leaked), repr(leaked))
195
204
 
196
205
  resp = driver.call("debug", action="stack")
197
206
  check("stack is available while paused", resp.get("ok") is True and bool(resp.get("out")), repr(resp.get("out")))
@@ -225,6 +234,16 @@ def main():
225
234
  check("driver answers ping", resp.get("ok") is True, repr(resp))
226
235
  check("engine is lazy before first use", resp.get("engineRunning") is False, repr(resp))
227
236
 
237
+ # Rejecting an unknown action must not pay a MATLAB launch. These
238
+ # run before any check that starts the engine, so "still lazy" is a
239
+ # meaningful assertion rather than a restatement of "already up".
240
+ resp = driver.call("debug", action="nonsense")
241
+ check("an invalid debug action is rejected", resp.get("ok") is False, repr(resp))
242
+ resp = driver.call("figure", action="nonsense")
243
+ check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
244
+ resp = driver.call("ping")
245
+ check("a rejected action does not launch MATLAB", resp.get("engineRunning") is False, repr(resp))
246
+
228
247
  run_eval_checks(driver)
229
248
  run_figure_checks(driver, tmp)
230
249
  if not args.no_debug:
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate what `npm pack` is about to publish.
4
+ *
5
+ * Reads the JSON that `npm pack --dry-run --json` writes to pack.json, so the
6
+ * assertions live in one file instead of being duplicated as inline `node -e`
7
+ * snippets in ci.yml and release.yml.
8
+ *
9
+ * WHY NOT AN INLINE `node -e`
10
+ * This package is `"type": "module"`, and a recent Node 22.x began evaluating
11
+ * `-e` input according to that field. Under ESM there is no `require`, so the
12
+ * previous inline version died with "require is not a function" -- on CI but
13
+ * not on the older Node used locally, which is the worst way for a check to
14
+ * fail. A script file has an unambiguous module type and needs no shell
15
+ * quoting either.
16
+ *
17
+ * WHY THE OUTPUT SHAPE IS NORMALIZED
18
+ * `npm pack --json` changed shape without a major-version signal in the CLI:
19
+ *
20
+ * npm <= 11 [ { name, entryCount, files: [{ path, size, mode }] } ]
21
+ * npm >= 12 { "<name>": { name, entryCount, files: [...] } }
22
+ *
23
+ * npm 12 passes `key: tar.name` to its logger, which wraps the record in an
24
+ * object keyed by package name (lib/utils/tar.js). A check that only handled
25
+ * the array form passed locally and failed on CI, which installs npm@latest.
26
+ * findPackRecord() accepts either, and the failure path prints the keys it did
27
+ * see so the next shape change is diagnosable from one CI log rather than a
28
+ * source-reading session.
29
+ *
30
+ * npm pack --dry-run --json > pack.json
31
+ * node scripts/inspect-tarball.mjs
32
+ */
33
+
34
+ import { readFileSync } from 'node:fs'
35
+ import process from 'node:process'
36
+
37
+ /**
38
+ * Files the runtime needs at run time. A `files` entry that drops one of these
39
+ * installs cleanly and then fails in the user's session, which is exactly the
40
+ * regression this list exists to catch.
41
+ */
42
+ const REQUIRED_FILES = [
43
+ 'src/plugin.mjs',
44
+ 'cordis.patch.yml',
45
+ 'python/ml_driver.py',
46
+ 'python/mfiles/dsh_evalbase.m',
47
+ 'python/mfiles/dsh_figure_info.m',
48
+ 'python/mfiles/dsh_figure_save.m',
49
+ 'scripts/setup-engine.mjs',
50
+ ]
51
+
52
+ /** Find the package record inside either the array or the keyed-object shape. */
53
+ function findPackRecord(pack) {
54
+ const hasFiles = (value) => value !== null && typeof value === 'object' && Array.isArray(value.files)
55
+ if (Array.isArray(pack)) return pack.find(hasFiles) ?? pack[0]
56
+ if (pack !== null && typeof pack === 'object') {
57
+ if (hasFiles(pack)) return pack
58
+ return Object.values(pack).find(hasFiles)
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ let pack
64
+ try {
65
+ pack = JSON.parse(readFileSync('pack.json', 'utf8'))
66
+ } catch (error) {
67
+ console.error('error: could not read pack.json')
68
+ console.error(' run `npm pack --dry-run --json > pack.json` first')
69
+ console.error(' ' + String((error && error.message) || error))
70
+ process.exit(1)
71
+ }
72
+
73
+ const entry = findPackRecord(pack)
74
+ if (entry === undefined) {
75
+ console.error('error: no package record with a file list in pack.json')
76
+ console.error(' top-level: ' + (Array.isArray(pack) ? 'array' : typeof pack))
77
+ const keys = Array.isArray(pack) ? Object.keys(pack[0] ?? {}) : Object.keys(pack ?? {})
78
+ console.error(' keys seen: ' + JSON.stringify(keys))
79
+ process.exit(1)
80
+ }
81
+
82
+ const paths = entry.files.map((file) => (typeof file === 'string' ? file : file.path))
83
+ const failures = []
84
+
85
+ // The engine runtime is MathWorks code laid out from the user's own MATLAB
86
+ // installation. Redistributing it through the registry would be a licence
87
+ // problem, so this is a hard stop rather than a warning.
88
+ const leaked = paths.filter((path) => typeof path === 'string' && path.includes('pylibs'))
89
+ if (leaked.length > 0) {
90
+ failures.push('engine runtime must not be published: ' + leaked.join(', '))
91
+ }
92
+
93
+ const missing = REQUIRED_FILES.filter((path) => !paths.includes(path))
94
+ if (missing.length > 0) {
95
+ failures.push('package is missing required files: ' + missing.join(', '))
96
+ }
97
+
98
+ if (failures.length > 0) {
99
+ for (const failure of failures) console.error('error: ' + failure)
100
+ process.exit(1)
101
+ }
102
+
103
+ const fileCount = entry.entryCount ?? paths.length
104
+ const sizeKb = Math.round((entry.size ?? 0) / 1024)
105
+ console.log('tarball ok: ' + fileCount + ' files, ' + sizeKb + ' kB, no engine runtime')