@deeeed/metamask-harness 0.51.6 → 0.51.8

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
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.51.8 - 2026-09-15
6
+
7
+ ### Fixed
8
+
9
+ - Mobile launch replaces a Metro process that Metro itself reported unusable (delta-graph corruption after a commit failed mid-way, or its "Detected a change in babel.config.js. Restart the server" advisory) by stopping it before `start-metro`, keeping the transform cache. Previously every "restart Metro" verdict and the `metro.restarted` recovery reused the live listener, so a Metro that outlived a bundler-config change failed every bundle until someone killed it by hand. Plain bundle errors still never restart Metro, and `--clear` stays explicit. `start-metro` now records the content hashes of the bundler config files so an advisory raised by an mtime-only touch is ignored; a Metro started before this release has no record, so its first advisory restarts it once.
10
+
11
+ ## 0.51.7 - 2026-09-14
12
+
13
+ ### Added
14
+
15
+ - `pr-body render <task-dir>` writes `artifacts/pr-body.md`: the worker's `artifacts/pr-description.md` with the "Validation Recipe", "Validation Logs" (and "Recipe Workflow" when `artifacts/workflow.mmd` exists) sections rendered from the task artifacts with fences that cannot break, replacing any pasted by hand. The farm publication step and the Cook evidence packaging call the same command.
16
+
17
+ ### Changed
18
+
19
+ - Extension launch focus hold polls every 80 ms instead of 20 ms, and the reopen launcher honours `MM_HARNESS_FOCUS_SETTLE_MS` like the primary launcher.
20
+
5
21
  ## 0.51.6 - 2026-09-14
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -193,6 +193,20 @@ Teams maintain their library checkouts. Update them before starting a task;
193
193
  the harness records local revisions and recipe digests but does not fetch or
194
194
  check for upstream updates. Keep the selected library unchanged during a run.
195
195
 
196
+ ## Publish a change
197
+
198
+ ```bash
199
+ # Write artifacts/pr-description.md in the repository PR template shape (prose
200
+ # only), then render the publishable body.
201
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --command 'mm-harness run artifacts/recipe.json'
202
+ ```
203
+
204
+ `pr-body render` writes `artifacts/pr-body.md`: the description with the "Validation
205
+ Recipe" and "Validation Logs" sections rendered from `artifacts/recipe.json` and
206
+ `artifacts/recipe-run/report.md` (and "Recipe Workflow" from `artifacts/workflow.mmd`
207
+ when it exists), replacing any pasted by hand. The worker never pastes artifacts
208
+ into markdown, so fences cannot break.
209
+
196
210
  ## Review a change
197
211
 
198
212
  ```bash
@@ -519,7 +519,9 @@ const releaseFocusHold = () => {
519
519
  if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
520
520
  await page.bringToFront().catch(() => {});
521
521
  } else {
522
- await new Promise(r => setTimeout(r, 1000));
522
+ const settleMs = Number(process.env.MM_HARNESS_FOCUS_SETTLE_MS);
523
+ const waitMs = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs : 1000;
524
+ if (waitMs > 0) await new Promise(r => setTimeout(r, waitMs));
523
525
  preserveMacFrontmost(previousFrontmost, chromiumApp);
524
526
  }
525
527
  releaseFocusHold();
@@ -408,11 +408,11 @@ function cdpListenerPids(port) {
408
408
  function terminatePids(pids) {
409
409
  const unique = [...new Set(pids)].filter((pid) => pid !== process.pid);
410
410
  for (const pid of unique) signalPid(pid, 'SIGTERM');
411
- for (let i = 0; i < 10 && unique.some(pidAlive); i += 1) {
411
+ for (let i = 0; i < 10 && unique.some(processRespondsToSignal); i += 1) {
412
412
  spawnSync('sleep', ['0.2']);
413
413
  }
414
414
  for (const pid of unique) {
415
- if (pidAlive(pid)) signalPid(pid, 'SIGKILL');
415
+ if (processRespondsToSignal(pid)) signalPid(pid, 'SIGKILL');
416
416
  }
417
417
  }
418
418
 
@@ -426,7 +426,7 @@ function signalPid(pid, signal) {
426
426
  }
427
427
  }
428
428
 
429
- function pidAlive(pid) {
429
+ function processRespondsToSignal(pid) {
430
430
  try {
431
431
  process.kill(pid, 0);
432
432
  return true;
@@ -3,7 +3,9 @@
3
3
  const { execFileSync, spawn } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
 
6
- const HOLD_INTERVAL_MS = 20;
6
+ // Window activation is not sub-20ms; 80ms keeps the hold responsive while
7
+ // spawning far fewer lsappinfo/open subprocesses per second.
8
+ const HOLD_INTERVAL_MS = 80;
7
9
  // Upper bound for one Launch Services query or `open -a`. A short bound made a
8
10
  // loaded Mac time out the first query, which silently disabled the whole hold.
9
11
  const LS_TIMEOUT_MS = 2000;
@@ -28,6 +30,7 @@ function captureMacFrontmost() {
28
30
  timeout: LS_TIMEOUT_MS,
29
31
  }));
30
32
  } catch {
33
+ // Best effort: lsappinfo missing, timed out, or the app exited; no focus to preserve.
31
34
  return null;
32
35
  }
33
36
  }
@@ -62,6 +65,7 @@ function currentFrontmostPid() {
62
65
  const pid = Number.parseInt((value.match(/"pid"=(\d+)/u) || [])[1], 10);
63
66
  return Number.isInteger(pid) && pid > 0 ? pid : null;
64
67
  } catch {
68
+ // Best effort: lsappinfo unavailable or timed out; callers treat null as unknown.
65
69
  return null;
66
70
  }
67
71
  }
@@ -76,6 +80,7 @@ function restoreMacFrontmost(target) {
76
80
  });
77
81
  return true;
78
82
  } catch {
83
+ // Best effort: `open -a` failed or timed out; the operator keeps whatever is in front.
79
84
  return false;
80
85
  }
81
86
  }
@@ -97,6 +102,7 @@ function restoreMacFrontmostProcess(pid) {
97
102
  timeout: LS_TIMEOUT_MS,
98
103
  })));
99
104
  } catch {
105
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; nothing to restore.
100
106
  return false;
101
107
  }
102
108
  }
@@ -152,6 +158,7 @@ function sameBundle(left, right) {
152
158
  try {
153
159
  return fs.realpathSync(value);
154
160
  } catch {
161
+ // A path that cannot be resolved is compared as given.
155
162
  return value;
156
163
  }
157
164
  };
@@ -173,6 +180,7 @@ function bundlePathForPid(pid) {
173
180
  timeout: LS_TIMEOUT_MS,
174
181
  }))?.bundlePath ?? null;
175
182
  } catch {
183
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; the hold is skipped.
176
184
  return null;
177
185
  }
178
186
  }
@@ -192,6 +200,7 @@ function parentAlive(pid) {
192
200
  process.kill(pid, 0);
193
201
  return true;
194
202
  } catch {
203
+ // ESRCH or EPERM: the parent is gone or not ours; either way the hold must end.
195
204
  return false;
196
205
  }
197
206
  }
@@ -55,6 +55,7 @@ function pidAlive(pid) {
55
55
  if (error.code === 'ESRCH') return false;
56
56
  throw error;
57
57
  }
58
+ // Requires macOS or procps ps; a ps that rejects these flags prints nothing, which is treated as dead.
58
59
  const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
59
60
  encoding: 'utf8',
60
61
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -257,6 +257,27 @@ printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FIL
257
257
  ROTATION_REASON="metro-start"
258
258
  [ "$CLEAR" = true ] && ROTATION_REASON="clear-cache-restart"
259
259
  node "$METRO_LOG_GENERATION" "$LOG_DIR" "$PORT" "$ROTATION_REASON" >/dev/null || exit 1
260
+ # Record what this process loads from the files Expo's FileNotifier watches, so
261
+ # the readiness decision can tell a real config change from an mtime-only touch
262
+ # when Metro later prints its "Detected a change in <file>" advisory.
263
+ record_metro_config_hashes() {
264
+ local file hash hashes=""
265
+ for file in babel.config.js metro.config.js metro.transform.js app.json app.config.js; do
266
+ [ -f "$TARGET/$file" ] || continue
267
+ if command -v shasum >/dev/null 2>&1; then
268
+ hash="$(shasum -a 256 "$TARGET/$file" | cut -d' ' -f1)"
269
+ else
270
+ hash="$(sha256sum "$TARGET/$file" | cut -d' ' -f1)"
271
+ fi
272
+ # An entry without a hash could never match and would force a restart on
273
+ # every advisory; leave the file out so the decision fails closed to
274
+ # "changed" for that file only, visibly.
275
+ [ -n "$hash" ] || continue
276
+ hashes+="$hash $file"$'\n'
277
+ done
278
+ printf '%s' "$hashes" > "$LOG_DIR/metro-config.sha256"
279
+ }
280
+ record_metro_config_hashes
260
281
 
261
282
  write_metro_build_env || {
262
283
  printf 'start-metro: could not stage the scoped Metro environment\n' >&2
@@ -6,7 +6,8 @@
6
6
  set -euo pipefail
7
7
 
8
8
  TARGET="."
9
- PORT="${WATCHER_PORT:-}"
9
+ # Same fallback order as start-metro so stop and start always agree on the port.
10
+ PORT="${WATCHER_PORT:-${METRO_PORT:-}}"
10
11
  while [ $# -gt 0 ]; do
11
12
  case "$1" in
12
13
  --target) TARGET="$2"; shift 2 ;;
@@ -14,7 +15,7 @@ while [ $# -gt 0 ]; do
14
15
  -h|--help)
15
16
  printf 'Usage: stop-metro.sh [--target <dir>] [--port <port>]\n'
16
17
  printf ' --target MetaMask Mobile checkout directory\n'
17
- printf ' --port Metro port (default: WATCHER_PORT env, else the slot context, else 8081)\n'
18
+ printf ' --port Metro port (default: WATCHER_PORT env, else METRO_PORT, else the slot context, else 8081)\n'
18
19
  exit 0
19
20
  ;;
20
21
  *) printf 'stop-metro: unknown arg: %s\n' "$1" >&2; exit 2 ;;
@@ -63,6 +64,7 @@ set +e
63
64
  rm -f "$PID_FILE"
64
65
  rm -f "$LOG_DIR/metro-launch.json"
65
66
  rm -f "$LOG_DIR/metro-build-env.sh"
67
+ rm -f "$LOG_DIR/metro-config.sha256"
66
68
 
67
69
  # start-metro runs a per-checkout console-forwarder holding the device debugger
68
70
  # slot; Metro going down must take it too, or the orphan keeps polling and later
@@ -386,6 +386,11 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
386
386
  const leaf = path.join(runnerDir, "adapters/mobile/yarn-setup.sh");
387
387
  return spawnScriptStreaming(leaf, ["--target", cwd], target, POD_PROBE_ENV);
388
388
  }
389
+ case "stop-metro": {
390
+ const leaf = path.join(runnerDir, "adapters/mobile/stop-metro.sh");
391
+ const port = watcherPort ?? Number(process.env.WATCHER_PORT || process.env.METRO_PORT || 8081);
392
+ return spawnScriptStreaming(leaf, ["--target", cwd, "--port", String(port)], target);
393
+ }
389
394
  case "start-metro": {
390
395
  const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
391
396
  const extra = action.argv ?? [];
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import {
@@ -16,6 +17,12 @@ import { mobileProductMarkers } from "./deps-markers.js";
16
17
  import { mobileMetroEnvCheck } from "./metro-env.js";
17
18
  const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
18
19
  const BUNDLE_OK = /Bundled \d+ms|iOS Bundled|Android Bundled|Finished bundling/u;
20
+ const METRO_GRAPH_ROLLBACK = /attempted to roll back a graph commit but there were still changes/u;
21
+ const METRO_GRAPH_COMMIT_THROW = /Got unexpected undefined/u;
22
+ const METRO_GRAPH_FRAME = /DeltaBundler\/Graph\.js/u;
23
+ const METRO_RESTART_ADVISORY = /Detected a change in ([\w.-]+)\. Restart the server/u;
24
+ const METRO_REASON_MAX = 200;
25
+ const METRO_CONFIG_HASHES_FILE = "metro-config.sha256";
19
26
  const NATIVE_MODULE_STALE = /\[runtime not ready\].*HybridObject "([^"]+)" - It has not yet been registered in the Nitro Modules HybridObjectRegistry/u;
20
27
  function resolveMetroLog(target, metroLog) {
21
28
  const abs = metroLog ? path.isAbsolute(metroLog) ? metroLog : path.join(target, metroLog) : recipeRuntimePath(target, "metro.log");
@@ -87,6 +94,46 @@ function appRunningOnDevice(platform) {
87
94
  return false;
88
95
  }
89
96
  }
97
+ function metroConfigUnchangedSinceStart(target, relative) {
98
+ let recorded;
99
+ try {
100
+ recorded = fs.readFileSync(recipeRuntimePath(target, METRO_CONFIG_HASHES_FILE), "utf8");
101
+ } catch {
102
+ return false;
103
+ }
104
+ const entry = recorded.split("\n").find((line) => line.endsWith(` ${relative}`));
105
+ if (!entry) return false;
106
+ try {
107
+ const current = createHash("sha256").update(fs.readFileSync(path.join(target, relative))).digest("hex");
108
+ return entry.startsWith(current);
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+ function metroProcessUnusable(target, logText) {
114
+ const lines = logText.split("\n");
115
+ let lastOk = -1;
116
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
117
+ if (BUNDLE_OK.test(lines[i])) {
118
+ lastOk = i;
119
+ break;
120
+ }
121
+ }
122
+ for (let i = lines.length - 1; i > lastOk; i -= 1) {
123
+ if (METRO_GRAPH_ROLLBACK.test(lines[i])) return lines[i].trim().slice(0, METRO_REASON_MAX);
124
+ if (METRO_GRAPH_COMMIT_THROW.test(lines[i]) && lines.slice(i + 1, i + 4).some((frame) => METRO_GRAPH_FRAME.test(frame))) {
125
+ return lines[i].trim().slice(0, METRO_REASON_MAX);
126
+ }
127
+ }
128
+ const advised = /* @__PURE__ */ new Set();
129
+ for (const line of lines) {
130
+ const advisory = METRO_RESTART_ADVISORY.exec(line);
131
+ if (!advisory || advised.has(advisory[1])) continue;
132
+ advised.add(advisory[1]);
133
+ if (!metroConfigUnchangedSinceStart(target, advisory[1])) return advisory[0];
134
+ }
135
+ return null;
136
+ }
90
137
  const launchActions = (target, clearMetro = false) => {
91
138
  const actions = [];
92
139
  actions.push({
@@ -218,6 +265,21 @@ async function computeMobileReadiness(resolved, options, fast) {
218
265
  ]
219
266
  };
220
267
  }
268
+ const unusable = metro.status !== "down" && metroLog.reason !== "unresolved-module" ? metroProcessUnusable(resolved, metroLogText) : null;
269
+ if (unusable) {
270
+ return {
271
+ schemaVersion: 1,
272
+ adapter: "mobile",
273
+ target: resolved,
274
+ decision: "launch",
275
+ reasonCode: "metro-restart-required",
276
+ reasons: [
277
+ `Metro reported its process unusable ("${unusable}"); restarting Metro and keeping its transform cache. Pass --clear-metro only if the same failure returns in the new process.`
278
+ ],
279
+ checks,
280
+ actions: [{ id: "stop-metro", cwd: resolved }, ...launchActions(resolved)]
281
+ };
282
+ }
221
283
  if (metroLog.status === "errors") {
222
284
  const depsSatisfied = deps.status === "current";
223
285
  if (depsSatisfied && (metroLog.reason === "stale-bundle-error" || metroLog.reason === "bundle-error")) {
@@ -11,6 +11,7 @@ const SPEC = {
11
11
  { name: "help", desc: "Load version-matched recipe guidance (help review composes the review guide)", args: ["review"], flags: ["--json", "--adapter", "--target", "--domain"] },
12
12
  { name: "review", desc: "Materialize a review checklist composed from the base review and a team library", args: ["checklist"], flags: ["--domain", "--since", "--base", "--out", "--adapter", "--target", "--json"] },
13
13
  { name: "domain", desc: "Which team library owns the change (declared value, else owned-paths.json)", flags: ["--domain", "--base", "--adapter", "--target", "--json"] },
14
+ { name: "pr-body", desc: "Render the publishable PR body: pr-description.md plus the recipe and run sections built from artifacts", args: ["render", "<task-dir>"], flags: ["--command", "--out", "--json"] },
14
15
  { name: "config", desc: "Per-engineer locations: libraries.<name>, references.<adapter>", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
15
16
  { name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
16
17
  { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ import { handleLast } from "./commands/last.js";
28
28
  import { handleReview } from "./commands/review.js";
29
29
  import { handleDomain } from "./commands/domain.js";
30
30
  import { handleConfig } from "./commands/config.js";
31
+ import { handlePrBody } from "./commands/pr-body.js";
31
32
  import { parseArgs, targetPath } from "./commands/parse-args.js";
32
33
  const COMMANDS = {
33
34
  actions: handleActions,
@@ -138,6 +139,7 @@ async function main(argv) {
138
139
  if (command === "review") return handleReview(argv.slice(1));
139
140
  if (command === "domain") return handleDomain(argv.slice(1));
140
141
  if (command === "config") return handleConfig(argv.slice(1));
142
+ if (command === "pr-body") return handlePrBody(argv.slice(1));
141
143
  const handler = COMMANDS[command];
142
144
  if (!handler) throw new Error(`Unknown command: ${command}`);
143
145
  return handler(parseArgs(argv.slice(1), command));
@@ -77,6 +77,14 @@ const PUBLIC_COMMAND_CONTRACTS = {
77
77
  "--base": value()
78
78
  })
79
79
  },
80
+ "pr-body": {
81
+ options: options(HELP, JSON, {
82
+ "--command": value(),
83
+ "--out": value()
84
+ }),
85
+ positionals: [{ label: "action", choices: ["render"] }, { label: "task-dir" }],
86
+ minimumPositionals: 2
87
+ },
80
88
  config: {
81
89
  options: options(HELP, JSON),
82
90
  positionals: [
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { BODY_FILE, renderPrBody } from "../pr-body/render.js";
4
+ import { optionFlag, optionString, parseArgs, usageError } from "./parse-args.js";
5
+ const USAGE = "Usage: mm-harness pr-body render <task-dir> [--command <text>] [--out <file>] [--json]";
6
+ async function handlePrBody(argv) {
7
+ const parsed = parseArgs(argv, "pr-body");
8
+ const [action, taskArg] = parsed.positional;
9
+ if (action !== "render" || !taskArg) throw usageError(USAGE);
10
+ const taskDir = path.resolve(taskArg);
11
+ if (!fs.existsSync(path.join(taskDir, "artifacts"))) {
12
+ throw usageError(`${taskDir} has no artifacts/ directory; pass the task directory mm-harness task init wrote.`);
13
+ }
14
+ const command = optionString(parsed.options, "command");
15
+ const result = renderPrBody({ taskDir, ...command ? { command } : {} });
16
+ const out = path.resolve(optionString(parsed.options, "out") ?? path.join(taskDir, "artifacts", BODY_FILE));
17
+ fs.mkdirSync(path.dirname(out), { recursive: true });
18
+ fs.writeFileSync(out, result.body);
19
+ if (optionFlag(parsed.options, "json")) {
20
+ process.stdout.write(`${JSON.stringify({ command: "pr-body", subcommand: "render", status: "ok", taskDir, out, ...result, body: void 0 }, null, 2)}
21
+ `);
22
+ } else {
23
+ process.stdout.write(`pr body: ${out}
24
+ ${result.sections.join("\n ")}
25
+ `);
26
+ }
27
+ return 0;
28
+ }
29
+ export {
30
+ handlePrBody
31
+ };
@@ -91,6 +91,28 @@ Example:
91
91
  mm-harness domain
92
92
  mm-harness domain --json`
93
93
  },
94
+ {
95
+ name: "pr-body",
96
+ summary: "Render the publishable PR body: the authored description plus the recipe and run log sections built from artifacts.",
97
+ example: "mm-harness pr-body render temp/tasks/dev/TAT-1",
98
+ helpText: `mm-harness pr-body render <task-dir> [flags]
99
+
100
+ Reads <task-dir>/artifacts/pr-description.md (the PR body the worker wrote in
101
+ the repository PR template shape) and inserts the machine sections rendered
102
+ from artifacts: "Validation Recipe" from artifacts/recipe.json, "Validation
103
+ Logs" from artifacts/recipe-run/report.md, and "Recipe Workflow" from
104
+ artifacts/workflow.mmd when it exists. Any such section the worker pasted by
105
+ hand is replaced. The sections go before the first checklist section, else at
106
+ the end. Fences are always longer than any backtick run in the content and
107
+ close on their own line. Writes <task-dir>/artifacts/pr-body.md.
108
+
109
+ --command <text> Recipe invocation to show above the run log
110
+ --out <file> Write elsewhere than artifacts/pr-body.md
111
+ --json Machine-readable result
112
+
113
+ Example:
114
+ mm-harness pr-body render temp/tasks/dev/TAT-1 --json`
115
+ },
94
116
  {
95
117
  name: "config",
96
118
  summary: "Per-engineer machine locations for team libraries and reference checkouts.",
@@ -856,7 +878,7 @@ const HELP_GROUPS = [
856
878
  {
857
879
  title: "PROVE",
858
880
  blurb: "run recipes and inspect readiness",
859
- commands: ["run", "last", "doctor", "check", "checklist", "review", "recipe-quality"]
881
+ commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
860
882
  },
861
883
  {
862
884
  title: "RUNTIME OVERLAY",
@@ -0,0 +1,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PROSE_FILE = "pr-description.md";
4
+ const BODY_FILE = "pr-body.md";
5
+ const RECIPE_HEADING = "## **Validation Recipe**";
6
+ const LOGS_HEADING = "## **Validation Logs**";
7
+ const WORKFLOW_HEADING = "## **Recipe Workflow**";
8
+ function headingKey(heading) {
9
+ return heading.replace(/^#+\s*/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
10
+ }
11
+ const MACHINE_KEYS = new Set([RECIPE_HEADING, LOGS_HEADING, WORKFLOW_HEADING].map(headingKey));
12
+ function levelTwoHeadingLines(lines) {
13
+ const found = [];
14
+ lines.forEach((line, index) => {
15
+ if (/^ {0,3}##(?!#)\s+\S/.test(line)) found.push(index);
16
+ });
17
+ return found;
18
+ }
19
+ function fenceFor(content) {
20
+ let longest = 0;
21
+ for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
22
+ return "`".repeat(Math.max(3, longest + 1));
23
+ }
24
+ function fenced(content, info = "") {
25
+ const fence = fenceFor(content);
26
+ return `${fence}${info}
27
+ ${content.replace(/\n+$/, "")}
28
+ ${fence}`;
29
+ }
30
+ function escapeHtml(text) {
31
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32
+ }
33
+ function countRecipeNodes(recipe) {
34
+ if (!recipe || typeof recipe !== "object") return 0;
35
+ const record = recipe;
36
+ if (Array.isArray(record.nodes)) return record.nodes.length;
37
+ if (Array.isArray(record.steps)) return record.steps.length;
38
+ const workflow = record.workflow;
39
+ if (!workflow || typeof workflow !== "object") return 0;
40
+ return Object.values(workflow).reduce(
41
+ (total, value) => total + (Array.isArray(value) ? value.length : 0),
42
+ 0
43
+ );
44
+ }
45
+ function recipeSection(file) {
46
+ const recipe = JSON.parse(fs.readFileSync(file, "utf8"));
47
+ const nodes = countRecipeNodes(recipe);
48
+ const record = recipe && typeof recipe === "object" ? recipe : {};
49
+ const title = typeof record.title === "string" ? record.title : typeof record.description === "string" ? record.description : "";
50
+ const summary = escapeHtml(`recipe.json (${nodes} steps${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
51
+ return {
52
+ nodes,
53
+ body: [`<details><summary>${summary}</summary>`, "", fenced(JSON.stringify(recipe, null, 2), "json"), "</details>"].join("\n")
54
+ };
55
+ }
56
+ function logsSection(file, command) {
57
+ const report = fs.readFileSync(file, "utf8");
58
+ const status = /^Status:\s*(\S+)/m.exec(report)?.[1];
59
+ const nodes = /^Nodes:\s*(.+?)\s*$/m.exec(report)?.[1];
60
+ const summary = escapeHtml(status || nodes ? `Full output (${[nodes, status].filter(Boolean).join(", ")})` : "Full output");
61
+ const lines = [];
62
+ if (command) lines.push("Command:", "", fenced(command, "bash"), "");
63
+ lines.push(`<details><summary>${summary}</summary>`, "", fenced(report), "</details>");
64
+ return lines.join("\n");
65
+ }
66
+ function trimEnd(lines) {
67
+ let end = lines.length;
68
+ while (end > 0 && !lines[end - 1].trim()) end -= 1;
69
+ return lines.slice(0, end);
70
+ }
71
+ function renderPrBody(input) {
72
+ const artifacts = path.join(input.taskDir, "artifacts");
73
+ const prosePath = path.join(artifacts, PROSE_FILE);
74
+ if (!fs.existsSync(prosePath)) {
75
+ throw new Error(`Missing ${path.relative(input.taskDir, prosePath)}: write the PR description there first.`);
76
+ }
77
+ const recipePath = path.join(artifacts, "recipe.json");
78
+ const reportPath = path.join(artifacts, "recipe-run", "report.md");
79
+ const workflowPath = path.join(artifacts, "workflow.mmd");
80
+ const machine = [];
81
+ let recipe = null;
82
+ if (fs.existsSync(recipePath)) {
83
+ const rendered = recipeSection(recipePath);
84
+ recipe = { nodes: rendered.nodes, path: path.relative(input.taskDir, recipePath) };
85
+ machine.push([RECIPE_HEADING, rendered.body]);
86
+ } else {
87
+ machine.push([RECIPE_HEADING, "Not applicable: this task has no `artifacts/recipe.json`."]);
88
+ }
89
+ const hasReport = fs.existsSync(reportPath);
90
+ machine.push([
91
+ LOGS_HEADING,
92
+ hasReport ? logsSection(reportPath, input.command) : "Not applicable: no recipe run report under `artifacts/recipe-run/`."
93
+ ]);
94
+ const hasWorkflow = fs.existsSync(workflowPath);
95
+ if (hasWorkflow) {
96
+ machine.push([
97
+ WORKFLOW_HEADING,
98
+ ["<details><summary>workflow.mmd</summary>", "", fenced(fs.readFileSync(workflowPath, "utf8"), "mermaid"), "</details>"].join("\n")
99
+ ]);
100
+ }
101
+ const lines = fs.readFileSync(prosePath, "utf8").replace(/\r\n?/g, "\n").split("\n");
102
+ const headings = levelTwoHeadingLines(lines);
103
+ const keep = new Array(lines.length).fill(true);
104
+ let insertAt = lines.length;
105
+ headings.forEach((start, at) => {
106
+ const end = headings[at + 1] ?? lines.length;
107
+ const key = headingKey(lines[start]);
108
+ if (MACHINE_KEYS.has(key)) keep.fill(false, start, end);
109
+ else if (insertAt === lines.length && /checklist/.test(key)) insertAt = start;
110
+ });
111
+ const before = lines.slice(0, insertAt).filter((_, index) => keep[index]);
112
+ const after = lines.slice(insertAt).filter((_, index) => keep[insertAt + index]);
113
+ const generated = machine.flatMap(([heading, body2]) => [heading, "", body2, ""]);
114
+ const body = [...trimEnd(before), "", ...generated, ...after].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
115
+ return {
116
+ body,
117
+ sections: machine.map(([heading]) => heading),
118
+ recipe,
119
+ run: hasReport ? { path: path.relative(input.taskDir, reportPath) } : null,
120
+ workflow: hasWorkflow ? { path: path.relative(input.taskDir, workflowPath) } : null
121
+ };
122
+ }
123
+ export {
124
+ BODY_FILE,
125
+ LOGS_HEADING,
126
+ PROSE_FILE,
127
+ RECIPE_HEADING,
128
+ WORKFLOW_HEADING,
129
+ countRecipeNodes,
130
+ escapeHtml,
131
+ fenceFor,
132
+ fenced,
133
+ headingKey,
134
+ levelTwoHeadingLines,
135
+ renderPrBody
136
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.51.6",
3
+ "version": "0.51.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"