@deeeed/metamask-harness 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Evaluate a JS expression in the app's Hermes runtime via CDP Runtime.evaluate.
5
+ * Returns the evaluated value (primitives and JSON-serialisable objects).
6
+ */
7
+ async function cdpEval(client, expression) {
8
+ // Hermes doesn't support async in Runtime.evaluate, use a plain IIFE
9
+ const wrapped = `(function() { return (${expression}); })()`;
10
+ const result = await client.send('Runtime.evaluate', {
11
+ expression: wrapped,
12
+ returnByValue: true,
13
+ awaitPromise: false,
14
+ generatePreview: false,
15
+ });
16
+
17
+ if (result.exceptionDetails) {
18
+ const desc =
19
+ result.exceptionDetails.exception?.description ||
20
+ result.exceptionDetails.text ||
21
+ JSON.stringify(result.exceptionDetails);
22
+ throw new Error(`Evaluation error: ${desc}`);
23
+ }
24
+
25
+ return result.result?.value;
26
+ }
27
+
28
+ /**
29
+ * Evaluate a JS expression that returns a Promise.
30
+ * Hermes CDP doesn't support awaitPromise, so we store the result on
31
+ * globalThis.__cdp_async__ and poll for it.
32
+ */
33
+ async function cdpEvalAsync(client, expression, timeoutMs = cdpMessageTimeout()) {
34
+ // Unique key per call to avoid collisions
35
+ const key = `__cdp_async_${Date.now()}_${Math.random().toString(36).slice(2)}__`;
36
+
37
+ // Kick off the promise, store result when done.
38
+ // The try/catch guards against synchronous throws during argument evaluation
39
+ // of Promise.resolve(<expression>) — without it, a sync error escapes the
40
+ // IIFE and globalThis[key] stays 'pending' forever.
41
+ const kickoff = `(function() {
42
+ globalThis['${key}'] = { status: 'pending' };
43
+ try {
44
+ Promise.resolve(${expression})
45
+ .then(function(v) { globalThis['${key}'] = { status: 'resolved', value: v }; })
46
+ .catch(function(e) { globalThis['${key}'] = { status: 'rejected', error: String(e) }; });
47
+ } catch(e) {
48
+ globalThis['${key}'] = { status: 'rejected', error: String(e) };
49
+ }
50
+ return 'started';
51
+ })()`;
52
+
53
+ const kickoffResult = await client.send('Runtime.evaluate', {
54
+ expression: kickoff,
55
+ returnByValue: true,
56
+ awaitPromise: false,
57
+ }, timeoutMs);
58
+
59
+ // If the IIFE itself failed to evaluate (syntax error, etc.), bail early
60
+ if (kickoffResult.exceptionDetails) {
61
+ const desc =
62
+ kickoffResult.exceptionDetails.exception?.description ||
63
+ kickoffResult.exceptionDetails.text ||
64
+ JSON.stringify(kickoffResult.exceptionDetails);
65
+ throw new Error(`Async evaluation error: ${desc}`);
66
+ }
67
+
68
+ // Best-effort cleanup — swallow errors so a disconnected WebSocket
69
+ // doesn't obscure the actual result or diagnostic error.
70
+ const cleanup = () =>
71
+ client
72
+ .send('Runtime.evaluate', {
73
+ expression: `delete globalThis['${key}']`,
74
+ returnByValue: true,
75
+ awaitPromise: false,
76
+ })
77
+ // eslint-disable-next-line no-empty-function
78
+ .catch(() => {});
79
+
80
+ // Poll for completion
81
+ const pollInterval = 200;
82
+ const deadline = Date.now() + timeoutMs;
83
+ try {
84
+ while (Date.now() < deadline) {
85
+ await new Promise((r) => setTimeout(r, pollInterval));
86
+ const check = await client.send('Runtime.evaluate', {
87
+ expression: `(function() { return globalThis['${key}']; })()`,
88
+ returnByValue: true,
89
+ awaitPromise: false,
90
+ }, timeoutMs);
91
+ const val = check.result?.value;
92
+ if (val?.status === 'resolved') {
93
+ return val.value;
94
+ }
95
+ if (val?.status === 'rejected') {
96
+ throw new Error(`Async evaluation error: ${val.error}`);
97
+ }
98
+ }
99
+ throw new Error(`Async evaluation timed out after ${timeoutMs}ms`);
100
+ } finally {
101
+ await cleanup();
102
+ }
103
+ }
104
+
105
+ function cdpMessageTimeout() {
106
+ const timeoutMs = Number.parseInt(process.env.CDP_TIMEOUT || '30000', 10);
107
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30000;
108
+ }
109
+
110
+ module.exports = { cdpEval, cdpEvalAsync };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /** Read a value from .js.env */
7
+ function loadEnvValue(key) {
8
+ try {
9
+ const envPath = path.resolve(process.env.APP_ROOT || process.cwd(), '.js.env');
10
+ const content = fs.readFileSync(envPath, 'utf8');
11
+ // .js.env uses `export KEY="value"` (shell-sourceable format),
12
+ // so we handle the optional `export` prefix and strip surrounding quotes.
13
+ const match = content.match(new RegExp(String.raw`^(?:export\s+)?${key}=(.+)$`, 'm'));
14
+ if (match) return match[1].trim().replace(/^["']/, '').replace(/["']$/, '');
15
+ } catch {
16
+ // .js.env may not exist — fall through to undefined
17
+ }
18
+ return undefined;
19
+ }
20
+
21
+ /** Read WATCHER_PORT from .js.env or env (default: 8081) */
22
+ function loadPort() {
23
+ return Number.parseInt(process.env.WATCHER_PORT || loadEnvValue('WATCHER_PORT') || '8081', 10);
24
+ }
25
+
26
+ /** Read IOS_SIMULATOR name from .js.env or env (default: none — accept any device) */
27
+ function loadSimulatorName() {
28
+ // Explicit empty string in env means "no simulator" — don't fall through to .js.env
29
+ if ('IOS_SIMULATOR' in process.env) return process.env.IOS_SIMULATOR;
30
+ return loadEnvValue('IOS_SIMULATOR') || '';
31
+ }
32
+
33
+ /** Read ANDROID_DEVICE serial from .js.env or env (default: none — accept any device) */
34
+ function loadAndroidDevice() {
35
+ if ('ANDROID_DEVICE' in process.env) return process.env.ANDROID_DEVICE;
36
+ return loadEnvValue('ANDROID_DEVICE') || '';
37
+ }
38
+
39
+ module.exports = { loadEnvValue, loadPort, loadSimulatorName, loadAndroidDevice };
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { Buffer } = require('node:buffer');
6
+
7
+ // Shape of a single captured issue:
8
+ // { level, channel, source, targetRole, targetUrl, text, timestamp, allowlistMatch }
9
+ // level: 'warning' | 'error' | 'exception' | 'other'
10
+ // channel: 'console' | 'exception' | 'metro'
11
+ // source: 'app' | 'metro-log'
12
+
13
+ const LEVEL_WARNING = 'warning';
14
+ const LEVEL_ERROR = 'error';
15
+ const LEVEL_EXCEPTION = 'exception';
16
+ const LEVEL_OTHER = 'other';
17
+
18
+ const REVIEW_LEVELS = new Set([LEVEL_WARNING, LEVEL_ERROR, LEVEL_EXCEPTION]);
19
+ const TOP_ISSUES_LIMIT = 4;
20
+
21
+ const METRO_PATTERNS = [
22
+ { level: LEVEL_EXCEPTION, re: /\b(Uncaught|unhandledRejection|FATAL EXCEPTION|UnhandledPromiseRejection)\b/i },
23
+ { level: LEVEL_ERROR, re: /(^|\s)(\[error\]|ERROR |Error:)/i },
24
+ { level: LEVEL_WARNING, re: /(^|\s)(\[warn\]|WARN |Warning:)/i },
25
+ ];
26
+
27
+ function classifyMetroLine(line) {
28
+ if (!line) {
29
+ return null;
30
+ }
31
+ const trimmed = line.trim();
32
+ if (!trimmed) {
33
+ return null;
34
+ }
35
+ for (const { level, re } of METRO_PATTERNS) {
36
+ if (re.test(trimmed)) {
37
+ return { level, text: trimmed };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function captureFromMetro(logPath, startOffset) {
44
+ const issues = [];
45
+ if (!logPath || !fs.existsSync(logPath)) {
46
+ return issues;
47
+ }
48
+ let stat;
49
+ try {
50
+ stat = fs.statSync(logPath);
51
+ } catch {
52
+ return issues;
53
+ }
54
+ const start = Math.max(0, Math.min(startOffset || 0, stat.size));
55
+ if (stat.size <= start) {
56
+ return issues;
57
+ }
58
+ let slice = '';
59
+ try {
60
+ const fd = fs.openSync(logPath, 'r');
61
+ try {
62
+ const len = stat.size - start;
63
+ const buf = Buffer.alloc(len);
64
+ fs.readSync(fd, buf, 0, len, start);
65
+ slice = buf.toString('utf8');
66
+ } finally {
67
+ fs.closeSync(fd);
68
+ }
69
+ } catch {
70
+ return issues;
71
+ }
72
+
73
+ const lines = slice.split(/\r?\n/);
74
+ for (const line of lines) {
75
+ const classification = classifyMetroLine(line);
76
+ if (!classification) {
77
+ continue;
78
+ }
79
+ issues.push({
80
+ level: classification.level,
81
+ channel: 'metro',
82
+ source: 'metro-log',
83
+ targetRole: 'app',
84
+ targetUrl: 'metro://local',
85
+ text: classification.text.slice(0, 2000),
86
+ timestamp: new Date().toISOString(),
87
+ allowlistMatch: null,
88
+ });
89
+ }
90
+ return issues;
91
+ }
92
+
93
+ function normalizeAppBufferEntries(entries) {
94
+ if (!Array.isArray(entries)) {
95
+ return [];
96
+ }
97
+ return entries
98
+ .map((entry) => {
99
+ if (!entry || typeof entry !== 'object') {
100
+ return null;
101
+ }
102
+ const rawLevel = String(entry.level || '').toLowerCase();
103
+ let level = LEVEL_OTHER;
104
+ if (rawLevel === 'warn' || rawLevel === 'warning') {
105
+ level = LEVEL_WARNING;
106
+ } else if (rawLevel === 'error') {
107
+ level = LEVEL_ERROR;
108
+ } else if (rawLevel === 'exception') {
109
+ level = LEVEL_EXCEPTION;
110
+ }
111
+ const text = String(entry.text || '').slice(0, 2000);
112
+ if (!text) {
113
+ return null;
114
+ }
115
+ return {
116
+ level,
117
+ channel: level === LEVEL_EXCEPTION ? 'exception' : 'console',
118
+ source: 'app',
119
+ targetRole: 'app',
120
+ targetUrl: 'hermes://metamask',
121
+ text,
122
+ timestamp: entry.t ? new Date(Number(entry.t)).toISOString() : new Date().toISOString(),
123
+ allowlistMatch: null,
124
+ };
125
+ })
126
+ .filter(Boolean);
127
+ }
128
+
129
+ function dedupeIssues(issues) {
130
+ const seen = new Map();
131
+ for (const issue of issues) {
132
+ const key = `${issue.level}|${issue.text.slice(0, 300)}`;
133
+ const prior = seen.get(key);
134
+ if (prior) {
135
+ prior.count += 1;
136
+ // Prefer the in-app capture over the Metro mirror when both channels
137
+ // report the same logical issue text.
138
+ if (prior.source !== 'app' && issue.source === 'app') {
139
+ Object.assign(prior, issue, { count: prior.count });
140
+ }
141
+ } else {
142
+ seen.set(key, { ...issue, count: 1 });
143
+ }
144
+ }
145
+ return Array.from(seen.values());
146
+ }
147
+
148
+ function applyAllowlist(issues, allowlist) {
149
+ const unexpected = [];
150
+ const informational = [];
151
+ const rules = Array.isArray(allowlist) ? allowlist : [];
152
+ for (const issue of issues) {
153
+ let match = null;
154
+ for (const rule of rules) {
155
+ if (rule && rule.level && rule.level !== issue.level) {
156
+ continue;
157
+ }
158
+ if (rule && rule.textMatch) {
159
+ try {
160
+ const re = new RegExp(rule.textMatch);
161
+ if (re.test(issue.text)) {
162
+ match = rule.reason || rule.textMatch;
163
+ break;
164
+ }
165
+ } catch {
166
+ if (issue.text.includes(rule.textMatch)) {
167
+ match = rule.reason || rule.textMatch;
168
+ break;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ if (match) {
174
+ informational.push({ ...issue, allowlistMatch: match });
175
+ } else if (REVIEW_LEVELS.has(issue.level)) {
176
+ unexpected.push(issue);
177
+ } else {
178
+ informational.push(issue);
179
+ }
180
+ }
181
+ return { unexpected, informational };
182
+ }
183
+
184
+ function countByLevel(issues) {
185
+ const counts = { total: 0, warning: 0, error: 0, exception: 0, other: 0 };
186
+ for (const issue of issues) {
187
+ counts.total += 1;
188
+ if (counts[issue.level] !== undefined) {
189
+ counts[issue.level] += 1;
190
+ } else {
191
+ counts.other += 1;
192
+ }
193
+ }
194
+ return counts;
195
+ }
196
+
197
+ function matchesFailOn(issue, failOn) {
198
+ if (!failOn || typeof failOn !== 'object') {
199
+ return false;
200
+ }
201
+ const levels = Array.isArray(failOn.levels) ? failOn.levels : [];
202
+ const textMatches = Array.isArray(failOn.textMatches) ? failOn.textMatches : [];
203
+ if (levels.length && !levels.includes(issue.level)) {
204
+ return false;
205
+ }
206
+ if (!textMatches.length) {
207
+ return levels.length > 0;
208
+ }
209
+ for (const pattern of textMatches) {
210
+ try {
211
+ if (new RegExp(pattern).test(issue.text)) {
212
+ return true;
213
+ }
214
+ } catch {
215
+ if (issue.text.includes(pattern)) {
216
+ return true;
217
+ }
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
223
+ function computeReview(unexpected, informational, failOn, artifactFiles) {
224
+ const observedDeduped = dedupeIssues(unexpected);
225
+ const observed = countByLevel(observedDeduped);
226
+ const gatingIssues = observedDeduped.filter((issue) => matchesFailOn(issue, failOn));
227
+ const gating = countByLevel(gatingIssues);
228
+ const infoCount = Array.isArray(informational) ? informational.length : 0;
229
+
230
+ let status = 'clean';
231
+ let note = 'No unexpected warnings, errors, or exceptions observed.';
232
+ if (gating.total > 0) {
233
+ status = 'gating';
234
+ note = `Observed ${gating.total} unexpected warning/error/exception event(s) matching fail_on_unexpected. Recipe marked as failing.`;
235
+ } else if (observed.total > 0) {
236
+ status = 'review';
237
+ note = `Observed ${observed.total} unexpected warning/error/exception event(s) during validation. Relation to the recipe or current change is not determined; review the artifacts.`;
238
+ }
239
+
240
+ const ranked = observedDeduped.slice().sort((a, b) => {
241
+ if (b.count !== a.count) {
242
+ return b.count - a.count;
243
+ }
244
+ return (a.text || '').localeCompare(b.text || '');
245
+ });
246
+
247
+ const topIssues = ranked.slice(0, TOP_ISSUES_LIMIT).map((issue) => ({
248
+ level: issue.level,
249
+ channel: issue.channel,
250
+ source: issue.source,
251
+ targetRole: issue.targetRole,
252
+ targetUrl: issue.targetUrl,
253
+ text: issue.text,
254
+ allowlistMatch: issue.allowlistMatch,
255
+ }));
256
+
257
+ return {
258
+ status,
259
+ note,
260
+ observed,
261
+ gating,
262
+ informational: { total: infoCount },
263
+ topIssues,
264
+ artifactFiles: artifactFiles || {},
265
+ };
266
+ }
267
+
268
+ function renderMarkdown(review) {
269
+ const { status, note, observed, gating, informational, topIssues, artifactFiles } = review;
270
+ const lines = [];
271
+ lines.push('# Recipe Issue Review');
272
+ lines.push('');
273
+ lines.push(`Status: ${status}`);
274
+ lines.push('');
275
+ lines.push(note);
276
+ lines.push('');
277
+ lines.push('Observed:');
278
+ lines.push(`- warnings: ${observed.warning || 0}`);
279
+ lines.push(`- errors: ${observed.error || 0}`);
280
+ lines.push(`- exceptions: ${observed.exception || 0}`);
281
+ lines.push(`- total: ${observed.total || 0}`);
282
+ lines.push('');
283
+ lines.push('Gating:');
284
+ lines.push(`- warnings: ${gating.warning || 0}`);
285
+ lines.push(`- errors: ${gating.error || 0}`);
286
+ lines.push(`- exceptions: ${gating.exception || 0}`);
287
+ lines.push(`- total: ${gating.total || 0}`);
288
+ lines.push('');
289
+ lines.push(`Informational-only events: ${(informational && informational.total) || 0}`);
290
+ lines.push('');
291
+ if (topIssues && topIssues.length) {
292
+ lines.push('Top issues:');
293
+ for (const issue of topIssues) {
294
+ const levelLabel = String(issue.level || 'other').toUpperCase();
295
+ lines.push(`- [${levelLabel}] ${issue.source}: ${issue.text}`);
296
+ }
297
+ lines.push('');
298
+ }
299
+ if (artifactFiles && Object.keys(artifactFiles).length) {
300
+ lines.push('Artifacts:');
301
+ for (const key of ['allIssues', 'consoleWarnings', 'consoleErrors', 'runtimeExceptions']) {
302
+ if (artifactFiles[key]) {
303
+ lines.push(`- ${artifactFiles[key]}`);
304
+ }
305
+ }
306
+ lines.push('');
307
+ }
308
+ return lines.join('\n');
309
+ }
310
+
311
+ function writeJsonArtifact(filePath, data) {
312
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
313
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`);
314
+ }
315
+
316
+ function writeArtifacts(runDir, { unexpected, informational, review }) {
317
+ const outDir = path.resolve(runDir);
318
+ fs.mkdirSync(outDir, { recursive: true });
319
+
320
+ const all = [...(unexpected || []), ...(informational || [])];
321
+
322
+ const paths = {
323
+ allIssues: path.join(outDir, 'agentic-issues.json'),
324
+ consoleWarnings: path.join(outDir, 'console-warnings.json'),
325
+ consoleErrors: path.join(outDir, 'console-errors.json'),
326
+ runtimeExceptions: path.join(outDir, 'runtime-exceptions.json'),
327
+ reviewJson: path.join(outDir, 'agentic-issues-review.json'),
328
+ reviewMd: path.join(outDir, 'agentic-issues-review.md'),
329
+ };
330
+
331
+ writeJsonArtifact(paths.allIssues, all);
332
+ writeJsonArtifact(
333
+ paths.consoleWarnings,
334
+ all.filter((i) => i.level === LEVEL_WARNING),
335
+ );
336
+ writeJsonArtifact(
337
+ paths.consoleErrors,
338
+ all.filter((i) => i.level === LEVEL_ERROR),
339
+ );
340
+ writeJsonArtifact(
341
+ paths.runtimeExceptions,
342
+ all.filter((i) => i.level === LEVEL_EXCEPTION),
343
+ );
344
+
345
+ const reviewWithPaths = { ...review, artifactFiles: paths };
346
+ writeJsonArtifact(paths.reviewJson, reviewWithPaths);
347
+ fs.writeFileSync(paths.reviewMd, renderMarkdown(reviewWithPaths));
348
+
349
+ return paths;
350
+ }
351
+
352
+ // Installed into the Hermes runtime via Runtime.evaluate. Returns a string
353
+ // so callers can embed it directly into eval expressions. Keeps the hook
354
+ // idempotent and bounded (500-entry cap).
355
+ function buildArmSnippet() {
356
+ return `(() => {
357
+ var g = globalThis;
358
+ if (g.__AGENTIC_ISSUES_INSTALLED__) { return { installed: true, reason: 'already-installed' }; }
359
+ g.__AGENTIC_ISSUES__ = [];
360
+ g.__AGENTIC_ISSUES_CAP__ = 500;
361
+ var push = function (entry) {
362
+ try {
363
+ if (g.__AGENTIC_ISSUES__.length < g.__AGENTIC_ISSUES_CAP__) {
364
+ g.__AGENTIC_ISSUES__.push(entry);
365
+ }
366
+ } catch (_e) {}
367
+ };
368
+ ['warn', 'error'].forEach(function (k) {
369
+ var orig = console[k] && console[k].bind(console);
370
+ if (!orig) return;
371
+ console[k] = function () {
372
+ var args = Array.prototype.slice.call(arguments);
373
+ try {
374
+ var text = args.map(function (a) {
375
+ if (a && a.stack) return String(a.stack);
376
+ if (typeof a === 'string') return a;
377
+ try { return JSON.stringify(a); } catch (_e) { return String(a); }
378
+ }).join(' ');
379
+ push({ t: Date.now(), level: k, text: text });
380
+ } catch (_e) {}
381
+ return orig.apply(console, args);
382
+ };
383
+ });
384
+ if (typeof g.addEventListener === 'function') {
385
+ try {
386
+ g.addEventListener('error', function (e) {
387
+ var err = e && (e.error || e.message);
388
+ var text = err && err.stack ? err.stack : String(err || e);
389
+ push({ t: Date.now(), level: 'exception', text: text });
390
+ });
391
+ g.addEventListener('unhandledrejection', function (e) {
392
+ var r = e && e.reason;
393
+ var text = r && r.stack ? r.stack : String(r || e);
394
+ push({ t: Date.now(), level: 'exception', text: text });
395
+ });
396
+ } catch (_e) {}
397
+ }
398
+ var ep = g.ErrorUtils;
399
+ if (ep && typeof ep.setGlobalHandler === 'function') {
400
+ try {
401
+ var priorHandler = typeof ep.getGlobalHandler === 'function' ? ep.getGlobalHandler() : null;
402
+ ep.setGlobalHandler(function (err, isFatal) {
403
+ try {
404
+ var text = err && err.stack ? err.stack : String(err);
405
+ push({ t: Date.now(), level: 'exception', text: (isFatal ? '[FATAL] ' : '') + text });
406
+ } catch (_e) {}
407
+ if (typeof priorHandler === 'function') {
408
+ try { priorHandler(err, isFatal); } catch (_e) {}
409
+ }
410
+ });
411
+ } catch (_e) {}
412
+ }
413
+ g.__AGENTIC_ISSUES_INSTALLED__ = true;
414
+ return { installed: true };
415
+ })()`;
416
+ }
417
+
418
+ function buildCollectSnippet() {
419
+ return `(() => {
420
+ var g = globalThis;
421
+ var buf = g.__AGENTIC_ISSUES__ || [];
422
+ var snapshot = buf.slice();
423
+ g.__AGENTIC_ISSUES__ = [];
424
+ return { count: snapshot.length, entries: snapshot };
425
+ })()`;
426
+ }
427
+
428
+ module.exports = {
429
+ LEVEL_WARNING,
430
+ LEVEL_ERROR,
431
+ LEVEL_EXCEPTION,
432
+ LEVEL_OTHER,
433
+ TOP_ISSUES_LIMIT,
434
+ applyAllowlist,
435
+ buildArmSnippet,
436
+ buildCollectSnippet,
437
+ captureFromMetro,
438
+ classifyMetroLine,
439
+ computeReview,
440
+ countByLevel,
441
+ dedupeIssues,
442
+ matchesFailOn,
443
+ normalizeAppBufferEntries,
444
+ renderMarkdown,
445
+ writeArtifacts,
446
+ };