@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 +16 -0
- package/README.md +14 -0
- package/adapters/extension/ensure-browser.sh +3 -1
- package/adapters/extension/launch-browser.cjs +3 -3
- package/adapters/extension/lib/macos-focus.cjs +10 -1
- package/adapters/extension/lib/validation-process-ownership.cjs +1 -0
- package/adapters/mobile/start-metro.sh +21 -0
- package/adapters/mobile/stop-metro.sh +4 -2
- package/dist/adapters/mobile/prepare.js +5 -0
- package/dist/adapters/mobile/runtime-decision.js +62 -0
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +2 -0
- package/dist/command-contract.js +8 -0
- package/dist/commands/pr-body.js +31 -0
- package/dist/mm-harness-cli.js +23 -1
- package/dist/pr-body/render.js +136 -0
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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")) {
|
package/dist/cli-commands.js
CHANGED
|
@@ -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));
|
package/dist/command-contract.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
};
|