@twinsearth/w2m-dsh-plugin 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.13.0] — 2026-10-15
8
+
9
+ **The root cause of three rounds of confusion was a regex, and it was mine.**
10
+
11
+ Several rounds were spent believing a suite *failed on macOS*. It had not. The parser was looking for lines
12
+ beginning `# tests 17`. Node v24 writes them beginning `ℹ tests 17`:
13
+
14
+ ```
15
+ ℹ tests 17 not # tests 17
16
+ ℹ pass 17 # pass 17
17
+ ℹ fail 0 # fail 0
18
+ ```
19
+
20
+ **The `#` form was an assumption that was never checked**, carried from the first script written in this
21
+ session to every one after it. The damage was worse than a misread count:
22
+
23
+ * suites that had **passed** were recorded as `UNREADABLE`;
24
+ * and then the absence of a `# fail` line, together with the absence of test *names* in the captured output,
25
+ was read as **"the process crashed before printing anything"** — which pointed at module scope and sent an
26
+ investigation somewhere the fault was not. Two rounds went into that, including a probe that correctly ruled
27
+ out every file-system hypothesis, because the hypothesis being tested was derived from a bad premise.
28
+
29
+ `src/agent/test-report.mjs` now writes the contract down, parses both prefixes plus a bare form, reads
30
+ **both streams and says which one answered**, and reports its own format so a future change is *reported*
31
+ rather than silently tolerated. 13 tests, and the two that matter most are:
32
+
33
+ * the **real** v24 output parses (taken verbatim from a run, not invented);
34
+ * an unreadable report yields `verdict(...) === null`, **not `false`** — collapsing "could not read" into
35
+ "failed" is the safer of the two wrong directions and was still wrong enough to waste rounds.
36
+
37
+ ### The lesson, stated so it generalises
38
+
39
+ **I assumed the shape of a channel's output instead of measuring it** — the same error, from the same
40
+ channel, for the third time in this session (first the exit-code summary line, then the test names, now the
41
+ prefix). The technique that finally settled it was printing the raw output and looking at it, which took one
42
+ command and should have been the first.
43
+
44
+ `test/log-bounds.test.mjs` remains skipped on macOS. With a working parser the skip is no longer plausible as
45
+ a parse artefact, so the underlying assertion failure is still **UNVERIFIED** — but the reason it took so long
46
+ to see is now fixed and tested.
47
+
7
48
  ## [0.12.3] — 2026-10-15
8
49
 
9
50
  **Correcting a wrong inference that 0.12.2 recorded as fact.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.12.3",
3
+ "version": "0.13.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -113,13 +113,13 @@
113
113
  }
114
114
  },
115
115
  "scripts": {
116
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
116
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
117
117
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
118
118
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
119
119
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
120
120
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
121
121
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
122
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
122
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/layer-model.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
123
123
  "pack": "node scripts/pack.mjs",
124
124
  "verify:join": "node scripts/verify-join.mjs",
125
125
  "lint": "eslint .",
@@ -139,7 +139,8 @@
139
139
  "test:council": "node --test --test-force-exit test/council.test.mjs",
140
140
  "test:layers": "node --test --test-force-exit test/layer-model.test.mjs",
141
141
  "test:log-bounds": "node --test --test-force-exit test/log-bounds.test.mjs",
142
- "layer-check": "node scripts/layer-check.mjs"
142
+ "layer-check": "node scripts/layer-check.mjs",
143
+ "test:test-report": "node --test --test-force-exit test/test-report.test.mjs"
143
144
  },
144
145
  "keywords": [
145
146
  "dsh-bundle",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Reading `node --test`'s summary, without assuming its shape.
3
+ *
4
+ * ## Why this module exists, told plainly because it cost three rounds
5
+ *
6
+ * Several rounds of this project were spent believing a suite *failed on macOS*. It had not. The parser was
7
+ * looking for lines beginning `# tests 17`, and node v24 writes them beginning `ℹ tests 17`:
8
+ *
9
+ * ℹ tests 17 not # tests 17
10
+ * ℹ pass 17 # pass 17
11
+ * ℹ fail 0 # fail 0
12
+ *
13
+ * **The `#` form was an assumption that was never checked.** It produced "UNREADABLE" for suites that had
14
+ * passed, and — worse — it produced a *wrong diagnosis*: because no `# fail` line could ever be found, and
15
+ * because no test *names* appeared either, the absence of both was read as "the process crashed before
16
+ * printing anything", which pointed at module scope and sent the investigation somewhere the fault was not.
17
+ * The actual fault was a regex.
18
+ *
19
+ * So the contract is written down here, parsed defensively, and tested against both forms. The one thing
20
+ * this module must never do is answer "unreadable" when the answer was merely "in a shape I did not expect".
21
+ *
22
+ * ## The two streams
23
+ *
24
+ * `node --test` writes its report to **stdout** on every version measured so far, and writes *diagnostics*
25
+ * to stderr. But "on every version measured so far" is exactly the kind of claim that ages badly, so the
26
+ * parser reads **both** streams and says which one it used. A caller that cannot tell which stream answered
27
+ * is a caller that will misread the next version change the same way.
28
+ */
29
+
30
+ /** Summary labels, as node has spelled them. Both are accepted; neither is assumed. */
31
+ export const LABELS = Object.freeze(['tests', 'pass', 'fail', 'skipped', 'cancelled', 'todo', 'duration_ms']);
32
+
33
+ /** The prefixes node has used for summary lines. `ℹ` is what v24 writes; `#` is what earlier TAP-shaped output used. */
34
+ export const PREFIXES = Object.freeze(['\u2139', '#']);
35
+
36
+ /**
37
+ * Strip a summary prefix and return `{label, value}`, or `null`.
38
+ *
39
+ * Tolerant on purpose: leading whitespace is allowed (node indents nested summaries), and the prefix may be
40
+ * followed by a space or not. A strict pattern here is what caused the original fault, and a strict pattern
41
+ * is not made safer by being strict in a different way.
42
+ */
43
+ export function parseSummaryLine(line) {
44
+ if (typeof line !== 'string') return null;
45
+ const trimmed = line.trim();
46
+ let body = null;
47
+ for (const prefix of PREFIXES) {
48
+ if (trimmed.startsWith(prefix)) {
49
+ body = trimmed.slice(prefix.length).trim();
50
+ break;
51
+ }
52
+ }
53
+ // A bare `tests 17` with no prefix is also accepted, because "no prefix" is a shape node has not used yet
54
+ // and refusing it would be the same mistake in a new costume.
55
+ if (body === null) body = trimmed;
56
+ const match = /^([a-z_]+)\s+(\d+(?:\.\d+)?)$/.exec(body);
57
+ if (match === null) return null;
58
+ const label = match[1];
59
+ if (!LABELS.includes(label)) return null;
60
+ return { label, value: Number(match[2]) };
61
+ }
62
+
63
+ /**
64
+ * Parse a full report.
65
+ *
66
+ * @param {string|{stdout?: string, stderr?: string}} input text, or the two streams separately
67
+ * @returns {{
68
+ * ok: boolean, source: 'stdout'|'stderr'|'both'|'none', format: 'info'|'hash'|'bare'|'unknown',
69
+ * tests: number|null, pass: number|null, fail: number|null, skipped: number|null,
70
+ * detail: string
71
+ * }}
72
+ */
73
+ export function parseTestReport(input) {
74
+ const stdout = typeof input === 'string' ? input : String(input?.stdout ?? '');
75
+ const stderr = typeof input === 'string' ? '' : String(input?.stderr ?? '');
76
+
77
+ const scan = (text) => {
78
+ const found = new Map();
79
+ let format = 'unknown';
80
+ for (const line of text.split('\n')) {
81
+ const parsed = parseSummaryLine(line);
82
+ if (parsed === null) continue;
83
+ // Record the shape that actually matched, so a future format change is reported rather than silently
84
+ // tolerated.
85
+ if (format === 'unknown') {
86
+ const trimmed = line.trim();
87
+ if (trimmed.startsWith('\u2139')) format = 'info';
88
+ else if (trimmed.startsWith('#')) format = 'hash';
89
+ else format = 'bare';
90
+ }
91
+ found.set(parsed.label, parsed.value);
92
+ }
93
+ return { found, format };
94
+ };
95
+
96
+ const out = scan(stdout);
97
+ const err = scan(stderr);
98
+ const fromStdout = out.found.size > 0;
99
+ const fromStderr = err.found.size > 0;
100
+ const source = fromStdout && fromStderr ? 'both' : fromStdout ? 'stdout' : fromStderr ? 'stderr' : 'none';
101
+ // stdout wins when both carry a summary, because that is where node has always put the report.
102
+ const chosen = fromStdout ? out : err;
103
+ const format = fromStdout ? out.format : fromStderr ? err.format : 'unknown';
104
+
105
+ if (!fromStdout && !fromStderr) {
106
+ return {
107
+ ok: false, source: 'none', format: 'unknown',
108
+ tests: null, pass: null, fail: null, skipped: null,
109
+ detail: 'no summary line found in either stream -- this is NOT the same as a failing suite, and the caller must not treat it as one',
110
+ };
111
+ }
112
+
113
+ const get = (label) => (chosen.found.has(label) ? chosen.found.get(label) : null);
114
+ const tests = get('tests');
115
+ const fail = get('fail');
116
+ return {
117
+ ok: true,
118
+ source,
119
+ format,
120
+ tests,
121
+ pass: get('pass'),
122
+ fail,
123
+ skipped: get('skipped'),
124
+ detail: tests === null
125
+ ? `read from ${source} (${format}) but no test count was present`
126
+ : `${tests} test(s), ${fail ?? '?'} failed, read from ${source} (${format})`,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Whether a report says the suite passed.
132
+ *
133
+ * **Three-valued on purpose.** `true`, `false`, and `null` for "the report could not be read" — because
134
+ * collapsing `null` into either boolean is exactly how an unread report becomes a pass or a phantom failure.
135
+ * The original bug turned unreadable reports into "failed", which is the safer of the two directions to be
136
+ * wrong in and was still wrong enough to waste rounds.
137
+ */
138
+ export function verdict(report) {
139
+ if (report === null || report === undefined || report.ok !== true) return null;
140
+ if (report.fail === null) return null;
141
+ return report.fail === 0;
142
+ }
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
185
185
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
186
186
  * overrides it at runtime for tests and packagers.
187
187
  */
188
- const PLUGIN_VERSION = '0.12.3';
188
+ const PLUGIN_VERSION = '0.13.0';
189
189
 
190
190
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
191
191
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;