@deeeed/metamask-harness 0.13.0 → 0.14.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +43 -4
  2. package/adapters/extension/inject.mjs +1 -0
  3. package/adapters/extension/launch-browser.cjs +15 -3
  4. package/adapters/extension/lib/extension-id.cjs +36 -0
  5. package/adapters/extension/live.sh +5 -3
  6. package/adapters/extension/readiness.mjs +48 -0
  7. package/adapters/extension/reattach.sh +163 -55
  8. package/adapters/extension/sidepanel-toggle.sh +96 -24
  9. package/adapters/extension/verify.sh +15 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  11. package/adapters/mobile/open-device.sh +32 -5
  12. package/adapters/mobile/start-metro.sh +15 -1
  13. package/adapters/mobile/verify.sh +50 -13
  14. package/adapters/shared/log-tui.mjs +7 -3
  15. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  16. package/dist/adapters/core/surface.js +1 -0
  17. package/dist/adapters/extension/runtime.js +14 -5
  18. package/dist/adapters/extension/surface.js +1 -0
  19. package/dist/adapters/mobile/provision.js +49 -4
  20. package/dist/adapters/mobile/surface.js +1 -0
  21. package/dist/adapters/slot-ports.js +11 -4
  22. package/dist/cli-commands.js +5 -4
  23. package/dist/cli.js +4 -0
  24. package/dist/commands/call.js +79 -6
  25. package/dist/commands/check.js +326 -0
  26. package/dist/commands/core-readiness.js +75 -0
  27. package/dist/commands/device-target.js +123 -13
  28. package/dist/commands/doctor.js +30 -18
  29. package/dist/commands/fixtures.js +2 -1
  30. package/dist/commands/launch/extension.js +105 -9
  31. package/dist/commands/launch/index.js +116 -15
  32. package/dist/commands/mobile-device-view.js +140 -0
  33. package/dist/commands/parse-args.js +4 -1
  34. package/dist/commands/recipe-quality.js +6 -2
  35. package/dist/commands/run-engine.js +90 -5
  36. package/dist/commands/run-report.js +115 -0
  37. package/dist/commands/run.js +115 -8
  38. package/dist/commands/self-test.js +1 -1
  39. package/dist/commands/shared.js +2 -1
  40. package/dist/commands/status-probe.js +9 -3
  41. package/dist/commands/status.js +45 -50
  42. package/dist/live-adapter-contract.js +5 -1
  43. package/dist/mm-harness-cli.js +68 -27
  44. package/dist/recipe-files.js +14 -0
  45. package/docs/CLI-SPEC.md +25 -0
  46. package/docs/architecture.md +2 -2
  47. package/docs/live-adapter-contract.md +1 -1
  48. package/docs/recipe-libraries.md +21 -18
  49. package/library/actions/extension/platform/cdp.mjs +7 -4
  50. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  51. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  52. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  53. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  54. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  55. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  56. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  57. package/package.json +1 -1
  58. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  59. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  60. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  61. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  62. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  63. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  64. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  65. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  66. /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
@@ -43,6 +43,7 @@ const extensionSurface = {
43
43
  }
44
44
  },
45
45
  devServer: {
46
+ label: "webpack",
46
47
  describe: () => "webpack watcher",
47
48
  stop(target) {
48
49
  const signalled = stopExtensionWatcher(target);
@@ -457,11 +457,13 @@ function preapproveDeepLinkScheme(device, bundleId, options) {
457
457
  function ensureSimulator(name, runtime, deviceType) {
458
458
  const existing = findSimulator(name);
459
459
  if (existing) return { name, udid: existing, created: false, runtime, deviceType };
460
- if (!runtime || !deviceType) {
461
- throw new Error(`simulator ${name} is missing and runtime/device type could not be resolved. Pass --runtime and --device-type, or write them to agentic-runtime.json.`);
460
+ const resolvedRuntime = runtime || latestIosRuntime();
461
+ const resolvedDeviceType = deviceType || preferredIphoneDeviceType();
462
+ if (!resolvedRuntime || !resolvedDeviceType) {
463
+ throw new Error(`simulator ${name} is missing and no available iOS simulator runtime/device type could be resolved from xcrun simctl.`);
462
464
  }
463
- const udid = execFileSync("xcrun", ["simctl", "create", name, deviceType, runtime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
464
- return { name, udid: udid || name, created: true, runtime, deviceType };
465
+ const udid = execFileSync("xcrun", ["simctl", "create", name, resolvedDeviceType, resolvedRuntime], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
466
+ return { name, udid: udid || name, created: true, runtime: resolvedRuntime, deviceType: resolvedDeviceType };
465
467
  }
466
468
  function findSimulator(name) {
467
469
  const data = execJson("xcrun", ["simctl", "list", "devices", "--json"]);
@@ -471,6 +473,49 @@ function findSimulator(name) {
471
473
  }
472
474
  return void 0;
473
475
  }
476
+ function latestIosRuntime() {
477
+ const data = execJson(
478
+ "xcrun",
479
+ ["simctl", "list", "runtimes", "--json"]
480
+ );
481
+ const runtimes = (data.runtimes ?? []).filter((runtime) => runtime.isAvailable !== false && runtime.identifier && (runtime.platform === "iOS" || /(^|[.-])iOS([.-]|$)/u.test(runtime.identifier) || /^iOS\b/u.test(runtime.name ?? ""))).sort((a, b) => compareVersions(runtimeVersion(b), runtimeVersion(a)));
482
+ return runtimes[0]?.identifier;
483
+ }
484
+ function preferredIphoneDeviceType() {
485
+ const data = execJson("xcrun", ["simctl", "list", "devicetypes", "--json"]);
486
+ const devices = (data.devicetypes ?? []).filter((device) => device.identifier && /^iPhone\b/u.test(device.name ?? "")).sort((a, b) => {
487
+ const generation = iphoneGeneration(b) - iphoneGeneration(a);
488
+ if (generation !== 0) return generation;
489
+ const proScore = iphoneProScore(b) - iphoneProScore(a);
490
+ if (proScore !== 0) return proScore;
491
+ return String(b.name ?? "").localeCompare(String(a.name ?? ""));
492
+ });
493
+ return devices[0]?.identifier;
494
+ }
495
+ function runtimeVersion(runtime) {
496
+ const raw = runtime.version || /iOS[-. ]([0-9][0-9A-Za-z_.-]*)/u.exec(runtime.identifier ?? "")?.[1] || runtime.name || "";
497
+ const parts = raw.match(/\d+/gu)?.map((part) => Number(part)) ?? [];
498
+ return parts.length ? parts : [0];
499
+ }
500
+ function compareVersions(a, b) {
501
+ const length = Math.max(a.length, b.length);
502
+ for (let i = 0; i < length; i += 1) {
503
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
504
+ if (diff !== 0) return diff;
505
+ }
506
+ return 0;
507
+ }
508
+ function iphoneGeneration(device) {
509
+ const match = /^iPhone\s+(\d+)/u.exec(device.name ?? "");
510
+ return match ? Number(match[1]) : 0;
511
+ }
512
+ function iphoneProScore(device) {
513
+ const name = device.name ?? "";
514
+ if (/\bPro Max\b/u.test(name)) return 3;
515
+ if (/\bPro\b/u.test(name)) return 2;
516
+ if (/\bPlus\b/u.test(name)) return 1;
517
+ return 0;
518
+ }
474
519
  function baselineSimulatorCandidates(data, current) {
475
520
  const candidates = [];
476
521
  if (current.simulator) candidates.push(current.simulator);
@@ -29,6 +29,7 @@ const mobileSurface = {
29
29
  }
30
30
  },
31
31
  devServer: {
32
+ label: "metro",
32
33
  describe: () => "Metro dev server",
33
34
  stop(target) {
34
35
  const leaf = path.join(runnerDir, "adapters", "mobile", "stop-metro.sh");
@@ -10,10 +10,8 @@ import {
10
10
  resolveMobileSlotDefaults
11
11
  } from "./resolve-slot-ports.js";
12
12
  function applyKVLines(output, overwrite) {
13
- for (const line of output.split("\n")) {
14
- const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
15
- if (!m) continue;
16
- const [, key, val] = m;
13
+ const entries = output.split("\n").map((line) => /^([A-Z_]+)=(.+)$/u.exec(line.trim())).filter((match) => match !== null).map((match) => ({ key: match[1], val: match[2] }));
14
+ for (const { key, val } of entries) {
17
15
  if (val === "null" || val === "undefined" || val === "") continue;
18
16
  switch (key) {
19
17
  case "WATCHER_PORT":
@@ -26,6 +24,15 @@ function applyKVLines(output, overwrite) {
26
24
  case "IOS_SIMULATOR":
27
25
  if (overwrite || !process.env["IOS_SIMULATOR"]) process.env["IOS_SIMULATOR"] = val;
28
26
  break;
27
+ case "ADB_SERIAL":
28
+ if (overwrite || !process.env["ADB_SERIAL"]) {
29
+ process.env["ADB_SERIAL"] = val;
30
+ process.env["ANDROID_SERIAL"] = val;
31
+ }
32
+ break;
33
+ case "ANDROID_DEVICE":
34
+ if (overwrite || !process.env["ANDROID_DEVICE"]) process.env["ANDROID_DEVICE"] = val;
35
+ break;
29
36
  case "SLOT_ID":
30
37
  if (overwrite || !process.env["RECIPE_SLOT_ID"]) process.env["RECIPE_SLOT_ID"] = val;
31
38
  break;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { listRecipeFiles } from "./recipe-files.js";
4
5
  const SPEC = {
5
6
  shim: [
6
7
  { name: "sync", desc: "Copy fixture, write slot context, install harness", flags: ["--dry-run", "--no-install"] },
@@ -16,7 +17,7 @@ const SPEC = {
16
17
  { name: "fixtures", desc: "Manage the canonical wallet fixture (sync/set/generate)", args: ["sync", "set", "generate"], flags: ["--fixture", "--out", "--adapter", "--target", "--device", "--json"] },
17
18
  { name: "actions", desc: "List runnable recipe actions", flags: ["--json"] },
18
19
  { name: "doctor", desc: "Check harness/orchestration health", flags: ["--json", "--target", "--adapter", "--runtime-dir", "--expect-live", "--cdp-port", "--device"] },
19
- { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
20
+ { name: "run", desc: "Execute a proof recipe (path or library name, e.g. run perps.smoke)", args: ["recipe.json|name"], flags: ["--list", "--device"] },
20
21
  { name: "recipe-quality", desc: "Build the recipe-quality artifact from compact JSON", args: ["build"], flags: ["--input", "--output", "--json"] },
21
22
  { name: "interactive", aliases: ["menu"], desc: "Interactive command menu" },
22
23
  { name: "prepare", desc: "Install harness (+ optional validate)", flags: ["--target", "--runtime-dir", "--json"] },
@@ -124,9 +125,9 @@ function listRunRecipes(adapter, root, cwd = process.cwd()) {
124
125
  for (const resolveDir of RUN_RECIPE_DIRS) {
125
126
  const dir = resolveDir(adapter, root);
126
127
  if (!fs.existsSync(dir)) continue;
127
- for (const name of fs.readdirSync(dir)) {
128
- if (!name.endsWith(".recipe.json") || byBase.has(name)) continue;
129
- byBase.set(name, path.join(dir, name));
128
+ for (const rel of listRecipeFiles(dir)) {
129
+ if (byBase.has(rel)) continue;
130
+ byBase.set(rel, path.join(dir, rel));
130
131
  }
131
132
  }
132
133
  return [...byBase.values()].map((abs) => path.relative(cwd, abs)).sort();
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { handleDebug } from "./commands/debug.js";
19
19
  import { handleFixtures } from "./commands/fixtures.js";
20
20
  import { handleRecipeQuality } from "./commands/recipe-quality.js";
21
21
  import { handleStatus } from "./commands/status.js";
22
+ import { handleCheck } from "./commands/check.js";
22
23
  import { parseArgs, targetPath } from "./commands/parse-args.js";
23
24
  import { runOneNode } from "./commands/run-engine.js";
24
25
  const COMMANDS = {
@@ -56,6 +57,8 @@ PROVE \u2014 run recipes and inspect capabilities:
56
57
  mm-harness flows list
57
58
  doctor Readiness check for a checkout (no app launch).
58
59
  mm-harness doctor
60
+ check Run bounded repo-local checks for an active diff.
61
+ mm-harness check diff --profile fast --artifacts-dir artifacts/validation
59
62
  actions Describe the actions a manifest declares.
60
63
  mm-harness actions --adapter mobile
61
64
  manifest Print/validate the action manifest for an adapter.
@@ -127,6 +130,7 @@ async function main(argv) {
127
130
  if (command === "debug") return handleDebug(argv.slice(1));
128
131
  if (command === "fixtures") return handleFixtures(argv.slice(1), { runOneNode });
129
132
  if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
133
+ if (command === "check") return handleCheck(argv.slice(1));
130
134
  const handler = COMMANDS[command];
131
135
  if (!handler) throw new Error(`Unknown command: ${command}`);
132
136
  return handler(parseArgs(argv.slice(1), command));
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { loadActionManifest } from "../manifest.js";
6
6
  import { importRecipeProtocol } from "../paths.js";
7
7
  import { recipeRunning } from "../heal-bounds.js";
8
+ import { color } from "../cli-color.js";
8
9
  import { EXIT } from "./shared.js";
9
10
  import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
10
11
  import {
@@ -16,8 +17,10 @@ import {
16
17
  runtimeOptionsFromCli,
17
18
  usageError
18
19
  } from "./parse-args.js";
20
+ import { getAdapterSurface } from "../adapters/surface.js";
19
21
  import { handleListExecutables } from "./list-executables.js";
20
22
  import { applyDeviceTargeting } from "./device-target.js";
23
+ import { coreDependencyBlock } from "./core-readiness.js";
21
24
  import {
22
25
  emitHealViolation,
23
26
  executeWithHealBounds,
@@ -33,7 +36,7 @@ async function handleCall(argv) {
33
36
  return handleListExecutables("call", options2);
34
37
  }
35
38
  if (argv.length > 0 && argv[0].startsWith("--")) {
36
- const message = "call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]";
39
+ const message = "call requires <action> first: mm-harness call <action> [key=value ...] [--arg k=v ...] [flags]";
37
40
  console.error(message);
38
41
  return EXIT.usage;
39
42
  }
@@ -60,6 +63,7 @@ async function handleCall(argv) {
60
63
  return EXIT.usage;
61
64
  }
62
65
  const { adapter, target } = resolveAdapter(options);
66
+ getAdapterSurface(adapter).resolveSlotPorts(target);
63
67
  const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
64
68
  if ("code" in dtResult) {
65
69
  if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
@@ -94,6 +98,16 @@ async function handleCall(argv) {
94
98
  return EXIT.usage;
95
99
  }
96
100
  const resolvedAction = resolution.resolved;
101
+ const depsBlock = adapter === "core" && resolvedAction.startsWith("metamask.perps.") ? coreDependencyBlock(target) : null;
102
+ if (depsBlock) {
103
+ if (json) {
104
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, status: "fail", exitCode: EXIT.usage, error: depsBlock }, null, 2));
105
+ } else {
106
+ console.error(`\u2717 call: ${depsBlock.message}`);
107
+ console.error(` Next: ${depsBlock.userAction}`);
108
+ }
109
+ return EXIT.usage;
110
+ }
97
111
  const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
98
112
  const validation = await validateRecipeAdapterAware(recipe, manifest);
99
113
  if (validation.status === "invalid") {
@@ -127,6 +141,7 @@ async function handleCall(argv) {
127
141
  state
128
142
  );
129
143
  if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
144
+ const callOutput = readCallOutput(result.tracePath);
130
145
  if (json) {
131
146
  console.log(
132
147
  JSON.stringify(
@@ -141,6 +156,7 @@ async function handleCall(argv) {
141
156
  summaryPath: result.summaryPath,
142
157
  tracePath: result.tracePath,
143
158
  artifactManifestPath: result.artifactManifestPath,
159
+ ...callOutput !== void 0 ? { output: callOutput } : {},
144
160
  recovered: state.recovered,
145
161
  mutations: state.mutations,
146
162
  exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
@@ -150,11 +166,34 @@ async function handleCall(argv) {
150
166
  )
151
167
  );
152
168
  } else {
153
- console.log(`call ${resolvedAction}: ${result.status}
154
- Artifacts: ${result.artifactManifestPath}`);
169
+ const rendered = callOutput !== void 0 ? `
170
+ Result:
171
+ ${formatCallOutput(callOutput)}` : "";
172
+ const out = (style, text) => color(style, text, { stream: process.stdout });
173
+ console.log(
174
+ `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
175
+ ${out("label", "Result:")}
176
+ ${formatCallOutput(callOutput)}` : ""}
177
+ ${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
178
+ );
155
179
  }
156
180
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
157
181
  }
182
+ function readCallOutput(tracePath) {
183
+ try {
184
+ const trace = JSON.parse(fs.readFileSync(tracePath, "utf8"));
185
+ const entries = Array.isArray(trace) ? trace : isRecord(trace) && Array.isArray(trace.entries) ? trace.entries : [];
186
+ const entry = entries.find((item) => isRecord(item) && item.nodeId === "call");
187
+ return isRecord(entry) && Object.prototype.hasOwnProperty.call(entry, "output") ? entry.output : void 0;
188
+ } catch {
189
+ return void 0;
190
+ }
191
+ }
192
+ function formatCallOutput(value) {
193
+ if (typeof value === "string") return value;
194
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
195
+ return JSON.stringify(value, null, 2);
196
+ }
158
197
  async function handleCallHelp(argv, genericHelp) {
159
198
  const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
160
199
  const { options } = parseArgs(rest, "call");
@@ -194,13 +233,13 @@ function renderCallActionHelp(entry) {
194
233
  const required = new Set(
195
234
  Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
196
235
  );
197
- const lines = [`mm-harness call ${entry.name} [--arg k=v ...] [flags]`, ""];
236
+ const lines = [`mm-harness call ${entry.name} [key=value ...] [--arg k=v ...] [flags]`, ""];
198
237
  if (entry.description) lines.push(` ${entry.description}`, "");
199
238
  const names = Object.keys(properties).sort();
200
239
  if (names.length === 0) {
201
240
  lines.push(" Fields: (none)");
202
241
  } else {
203
- lines.push(" Fields (pass with --arg <name>=<value>):");
242
+ lines.push(" Fields (pass as <name>=<value> or --arg <name>=<value>):");
204
243
  const width = Math.max(...names.map((name) => name.length));
205
244
  for (const name of names) {
206
245
  const prop = isRecord(properties[name]) ? properties[name] : {};
@@ -226,7 +265,7 @@ function renderCallExamples(short, examples) {
226
265
  for (const example of examples.slice(0, 2)) {
227
266
  const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
228
267
  if (!node) continue;
229
- const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `--arg ${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
268
+ const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
230
269
  out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
231
270
  }
232
271
  return out;
@@ -249,10 +288,44 @@ function parseCallArgs(argv) {
249
288
  action = arg;
250
289
  continue;
251
290
  }
291
+ if (action !== void 0 && !isForwardedOptionValue(argv, i) && isArgPair(arg)) {
292
+ const eq = arg.indexOf("=");
293
+ args[arg.slice(0, eq)] = arg.slice(eq + 1);
294
+ continue;
295
+ }
252
296
  rest.push(arg);
253
297
  }
254
298
  return { action, args, rest };
255
299
  }
300
+ function isArgPair(value) {
301
+ const eq = value.indexOf("=");
302
+ return eq > 0;
303
+ }
304
+ function isForwardedOptionValue(argv, index) {
305
+ if (index === 0) return false;
306
+ const previous = argv[index - 1];
307
+ if (!previous.startsWith("--") || previous.includes("=")) return false;
308
+ return VALUE_TAKING_CALL_FLAGS.has(normalizeFlagName(previous));
309
+ }
310
+ const VALUE_TAKING_CALL_FLAGS = /* @__PURE__ */ new Set([
311
+ "action-manifest",
312
+ "adapter",
313
+ "artifacts-dir",
314
+ "cdp-port",
315
+ "device",
316
+ "heal",
317
+ "library",
318
+ "metro-port",
319
+ "platform",
320
+ "project-root",
321
+ "slot",
322
+ "target",
323
+ "validation-runtime-dir",
324
+ "watcher-port"
325
+ ]);
326
+ function normalizeFlagName(flag) {
327
+ return flag.replace(/^--/u, "");
328
+ }
256
329
  function resolveActionName(shortName, names) {
257
330
  if (names.includes(shortName)) return { status: "ok", resolved: shortName, candidates: [shortName] };
258
331
  const finalSegment = (name) => name.split(".").pop() ?? name;
@@ -0,0 +1,326 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { detectAdapter } from "../harness.js";
5
+ import { EXIT, usageOut } from "./shared.js";
6
+ import { optionFlag, optionString, parseArgs } from "./parse-args.js";
7
+ const USAGE = "mm-harness check diff [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
8
+ const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
9
+ const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
10
+ const TEST_FILE_RE = /(?:^|[./_-])(?:test|spec)\.(?:c|m)?jsx?$|(?:^|[./_-])(?:test|spec)\.tsx?$/u;
11
+ async function handleCheck(argv) {
12
+ const { positional, options } = parseArgs(argv, "check");
13
+ const action = positional[0];
14
+ const json = optionFlag(options, "json");
15
+ if (action !== "diff") {
16
+ const message = action ? `unknown action '${action}'` : "missing action";
17
+ return usageOut(json, "check", message, USAGE);
18
+ }
19
+ const target = path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
20
+ const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
21
+ const detected = explicitAdapter ?? detectAdapter(target);
22
+ if (detected && !["mobile", "extension", "core"].includes(detected)) {
23
+ return usageOut(json, "check", `unsupported adapter '${detected}'`, USAGE);
24
+ }
25
+ const adapter = detected;
26
+ const profileInput = optionString(options, "profile");
27
+ if (profileInput && profileInput !== "fast" && profileInput !== "full") {
28
+ return usageOut(json, "check", "--profile must be fast or full", USAGE);
29
+ }
30
+ const profile = profileInput || "fast";
31
+ const explicitBaseRef = optionString(options, "base");
32
+ const base = explicitBaseRef ? { ref: explicitBaseRef, source: "flag" } : defaultBaseRef(target);
33
+ const baseRef = base.ref;
34
+ const artifactsDir = path.resolve(
35
+ optionString(options, "artifactsDir") ?? path.join(target, "temp", "recipe", "check-diff", timestampSlug())
36
+ );
37
+ fs.mkdirSync(artifactsDir, { recursive: true });
38
+ if (!isGitRepo(target)) {
39
+ return failEnvelope({
40
+ json,
41
+ adapter,
42
+ target,
43
+ profile,
44
+ baseRef,
45
+ artifactsDir,
46
+ message: `${target} is not a git checkout`,
47
+ code: "CHECK_DIFF_NOT_GIT"
48
+ });
49
+ }
50
+ const packageJson = readPackageJson(target);
51
+ const mergeBase = resolveMergeBase(target, baseRef);
52
+ const changedFiles = listChangedFiles(target, mergeBase, artifactsDir);
53
+ const existingChangedFiles = changedFiles.filter((file) => fs.existsSync(path.join(target, file)));
54
+ const checks = [];
55
+ checks.push(
56
+ runToolCheck({
57
+ id: "eslint",
58
+ label: "Changed-file ESLint",
59
+ target,
60
+ artifactsDir,
61
+ files: existingChangedFiles.filter((file) => JS_TS_EXT_RE.test(file)),
62
+ command: executable(target, "eslint"),
63
+ args: ["--cache", "--cache-location", path.join(artifactsDir, "eslintcache"), "--max-warnings=0", "--no-warn-ignored"]
64
+ })
65
+ );
66
+ checks.push(
67
+ runToolCheck({
68
+ id: "prettier",
69
+ label: "Changed-file Prettier",
70
+ target,
71
+ artifactsDir,
72
+ files: existingChangedFiles.filter((file) => FORMAT_EXT_RE.test(file)),
73
+ command: executable(target, "prettier"),
74
+ args: ["--check"]
75
+ })
76
+ );
77
+ checks.push(
78
+ runToolCheck({
79
+ id: "jest",
80
+ label: "Changed test files",
81
+ target,
82
+ artifactsDir,
83
+ files: existingChangedFiles.filter((file) => TEST_FILE_RE.test(file)),
84
+ command: executable(target, "jest"),
85
+ args: ["--no-coverage"]
86
+ })
87
+ );
88
+ if (profile === "full") {
89
+ checks.push(runTypecheck({ target, artifactsDir, packageJson }));
90
+ } else {
91
+ checks.push({
92
+ id: "typecheck",
93
+ label: "TypeScript project check",
94
+ status: "skip",
95
+ reason: "profile=fast; run with --profile full for repo-wide typecheck"
96
+ });
97
+ }
98
+ const status = checks.some((check) => check.status === "fail") ? "fail" : "pass";
99
+ const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
100
+ const summary = {
101
+ schemaVersion: 1,
102
+ command: "check",
103
+ action: "diff",
104
+ status,
105
+ exitCode,
106
+ adapter: adapter ?? null,
107
+ target,
108
+ profile,
109
+ baseRef,
110
+ baseSource: base.source,
111
+ baseLabel: base.label ?? null,
112
+ mergeBase,
113
+ changedFiles,
114
+ checks,
115
+ artifacts: {
116
+ dir: artifactsDir,
117
+ summaryJson: path.join(artifactsDir, "validation-summary.json"),
118
+ summaryMarkdown: path.join(artifactsDir, "validation-summary.md")
119
+ }
120
+ };
121
+ writeSummaryArtifacts(summary);
122
+ if (json) console.log(JSON.stringify(summary, null, 2));
123
+ else renderHuman(summary);
124
+ return exitCode;
125
+ }
126
+ function timestampSlug() {
127
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
128
+ }
129
+ function isGitRepo(target) {
130
+ return runCommand("git", ["rev-parse", "--is-inside-work-tree"], target).status === 0;
131
+ }
132
+ function defaultBaseRef(target) {
133
+ const prBase = githubPullRequestBase(target);
134
+ if (prBase) return prBase;
135
+ const remoteHead = runCommand("git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], target).stdout.trim();
136
+ if (remoteHead && verifyRef(target, remoteHead)) return { ref: remoteHead, source: "remote-head" };
137
+ for (const ref of ["origin/develop", "origin/main", "develop", "main", "HEAD"]) {
138
+ if (verifyRef(target, ref)) return { ref, source: "fallback" };
139
+ }
140
+ return { ref: "", source: "fallback" };
141
+ }
142
+ function githubPullRequestBase(target) {
143
+ const result = runCommand("gh", ["pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], target);
144
+ if (result.status !== 0) return null;
145
+ const baseName = result.stdout.trim();
146
+ if (!baseName) return null;
147
+ for (const ref of [`origin/${baseName}`, `upstream/${baseName}`, baseName]) {
148
+ if (verifyRef(target, ref)) return { ref, source: "github-pr", label: baseName };
149
+ }
150
+ return null;
151
+ }
152
+ function verifyRef(target, ref) {
153
+ return runCommand("git", ["rev-parse", "--verify", ref], target).status === 0;
154
+ }
155
+ function resolveMergeBase(target, baseRef) {
156
+ if (!baseRef) return null;
157
+ const result = runCommand("git", ["merge-base", "HEAD", baseRef], target);
158
+ if (result.status === 0) return result.stdout.trim() || null;
159
+ const verify = runCommand("git", ["rev-parse", "--verify", baseRef], target);
160
+ return verify.status === 0 ? verify.stdout.trim() || null : null;
161
+ }
162
+ function listChangedFiles(target, mergeBase, artifactsDir) {
163
+ const files = /* @__PURE__ */ new Set();
164
+ const addLines = (output) => {
165
+ for (const line of output.split("\n")) {
166
+ const file = line.trim();
167
+ if (file) files.add(file);
168
+ }
169
+ };
170
+ if (mergeBase) {
171
+ addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", `${mergeBase}...HEAD`], target).stdout);
172
+ }
173
+ addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"], target).stdout);
174
+ addLines(runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], target).stdout);
175
+ addLines(runCommand("git", ["ls-files", "--others", "--exclude-standard"], target).stdout);
176
+ return [...files].filter((file) => !isUnderArtifactsDir(target, artifactsDir, file)).sort();
177
+ }
178
+ function isUnderArtifactsDir(target, artifactsDir, file) {
179
+ const artifactRel = normalizeRelativePath(path.relative(path.resolve(target), path.resolve(artifactsDir)));
180
+ if (!artifactRel || artifactRel.startsWith("../") || artifactRel === "..") return false;
181
+ const normalized = normalizeRelativePath(file);
182
+ return normalized === artifactRel || normalized.startsWith(`${artifactRel}/`);
183
+ }
184
+ function normalizeRelativePath(value) {
185
+ return value.split(path.sep).join("/");
186
+ }
187
+ function executable(target, name) {
188
+ const local = path.join(target, "node_modules", ".bin", name);
189
+ return fs.existsSync(local) ? local : null;
190
+ }
191
+ function runToolCheck(input) {
192
+ if (input.files.length === 0) {
193
+ return { id: input.id, label: input.label, status: "skip", reason: "no changed matching files" };
194
+ }
195
+ if (!input.command) {
196
+ return { id: input.id, label: input.label, status: "skip", reason: `${input.id} binary not found in node_modules/.bin` };
197
+ }
198
+ return runCheckCommand(input.id, input.label, input.target, input.artifactsDir, input.command, [...input.args, ...input.files]);
199
+ }
200
+ function runTypecheck(input) {
201
+ const scripts = input.packageJson?.scripts && typeof input.packageJson.scripts === "object" ? input.packageJson.scripts : {};
202
+ if (typeof scripts["lint:tsc"] === "string") {
203
+ return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, "yarn", ["lint:tsc"]);
204
+ }
205
+ const tsc = executable(input.target, "tsc");
206
+ if (tsc) {
207
+ return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, tsc, ["--noEmit"]);
208
+ }
209
+ return { id: "typecheck", label: "TypeScript project check", status: "skip", reason: "no lint:tsc script or tsc binary found" };
210
+ }
211
+ function runCheckCommand(id, label, target, artifactsDir, command, args) {
212
+ const result = runCommand(command, args, target);
213
+ const logPath = path.join(artifactsDir, `${id}.log`);
214
+ fs.writeFileSync(logPath, renderCommandLog(command, args, result));
215
+ return {
216
+ id,
217
+ label,
218
+ status: result.status === 0 ? "pass" : "fail",
219
+ command: [command, ...args].join(" "),
220
+ exitCode: result.status,
221
+ logPath,
222
+ durationMs: result.durationMs
223
+ };
224
+ }
225
+ function runCommand(command, args, cwd) {
226
+ const started = Date.now();
227
+ const result = spawnSync(command, args, {
228
+ cwd,
229
+ encoding: "utf8",
230
+ env: process.env,
231
+ maxBuffer: 64 * 1024 * 1024
232
+ });
233
+ const status = result.error ? 127 : result.status ?? 1;
234
+ return {
235
+ status,
236
+ stdout: result.stdout ?? "",
237
+ stderr: result.error ? String(result.error) : result.stderr ?? "",
238
+ durationMs: Date.now() - started
239
+ };
240
+ }
241
+ function renderCommandLog(command, args, result) {
242
+ return [
243
+ `$ ${[command, ...args].join(" ")}`,
244
+ `exit: ${result.status}`,
245
+ `duration_ms: ${result.durationMs}`,
246
+ "",
247
+ "--- stdout ---",
248
+ result.stdout.trimEnd(),
249
+ "",
250
+ "--- stderr ---",
251
+ result.stderr.trimEnd(),
252
+ ""
253
+ ].join("\n");
254
+ }
255
+ function readPackageJson(target) {
256
+ try {
257
+ return JSON.parse(fs.readFileSync(path.join(target, "package.json"), "utf8"));
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ function writeSummaryArtifacts(summary) {
263
+ fs.writeFileSync(summary.artifacts.summaryJson, `${JSON.stringify(summary, null, 2)}
264
+ `);
265
+ fs.writeFileSync(summary.artifacts.summaryMarkdown, renderMarkdown(summary));
266
+ }
267
+ function renderMarkdown(summary) {
268
+ const lines = [
269
+ "# mm-harness check diff",
270
+ "",
271
+ `Verdict: ${summary.status}`,
272
+ `Profile: ${summary.profile}`,
273
+ `Base: ${summary.baseRef || "(none)"} (${summary.baseSource || "unknown"}${summary.baseLabel ? `: ${summary.baseLabel}` : ""})`,
274
+ `Changed files: ${summary.changedFiles.length}`,
275
+ "",
276
+ "## Checks",
277
+ ""
278
+ ];
279
+ for (const check of summary.checks) {
280
+ lines.push(`- ${check.status.toUpperCase()} ${check.id}${check.reason ? ` \u2014 ${check.reason}` : ""}${check.logPath ? ` (${check.logPath})` : ""}`);
281
+ }
282
+ lines.push("", "## Changed Files", "");
283
+ for (const file of summary.changedFiles) lines.push(`- ${file}`);
284
+ return `${lines.join("\n")}
285
+ `;
286
+ }
287
+ function renderHuman(summary) {
288
+ const mark = summary.status === "pass" ? "\u2713" : "\u2717";
289
+ const baseText = `${summary.baseRef || "(none)"}${summary.baseSource ? ` [${summary.baseSource}]` : ""}`;
290
+ console.log(`${mark} check diff: ${summary.status} \xB7 ${summary.changedFiles.length} changed file(s) \xB7 profile=${summary.profile} \xB7 base=${baseText}`);
291
+ if (summary.changedFiles.length > 0) {
292
+ console.log("Changed files:");
293
+ for (const file of summary.changedFiles.slice(0, 100)) console.log(` - ${file}`);
294
+ if (summary.changedFiles.length > 100) console.log(` ... ${summary.changedFiles.length - 100} more; full list in validation-summary.json`);
295
+ }
296
+ for (const check of summary.checks) {
297
+ const prefix = check.status === "pass" ? "\u2713" : check.status === "fail" ? "\u2717" : "-";
298
+ console.log(` ${prefix} ${check.id}: ${check.status}${check.reason ? ` (${check.reason})` : ""}`);
299
+ if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
300
+ }
301
+ console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
302
+ }
303
+ function failEnvelope(input) {
304
+ const exitCode = EXIT.usage;
305
+ const body = {
306
+ schemaVersion: 1,
307
+ command: "check",
308
+ action: "diff",
309
+ status: "fail",
310
+ exitCode,
311
+ adapter: input.adapter ?? null,
312
+ target: input.target,
313
+ profile: input.profile,
314
+ baseRef: input.baseRef,
315
+ baseSource: "unknown",
316
+ artifacts: { dir: input.artifactsDir },
317
+ error: { code: input.code, message: input.message }
318
+ };
319
+ if (input.json) console.log(JSON.stringify(body, null, 2));
320
+ else console.error(`\u2717 check diff: ${input.message}
321
+ Next: ${USAGE}`);
322
+ return exitCode;
323
+ }
324
+ export {
325
+ handleCheck
326
+ };