@deeeed/metamask-harness 0.36.0 → 0.38.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 (140) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +138 -0
  3. package/adapters/extension/inject.mjs +2 -0
  4. package/adapters/extension/launch-browser.cjs +24 -0
  5. package/adapters/extension/live.sh +82 -12
  6. package/adapters/extension/readiness.mjs +77 -36
  7. package/adapters/extension/snapshot-dist.sh +88 -3
  8. package/adapters/extension/verify.sh +6 -2
  9. package/adapters/manifest.json +17 -1
  10. package/adapters/shared/log-tui.mjs +1 -1
  11. package/dist/adapters/extension/artifact-integrity.js +38 -0
  12. package/dist/adapters/extension/extension-id.js +23 -4
  13. package/dist/adapters/extension/release-artifact.js +386 -0
  14. package/dist/adapters/extension/runtime-decision.js +164 -20
  15. package/dist/adapters/extension/runtime.js +169 -3
  16. package/dist/adapters/mobile/provision.js +38 -3
  17. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  18. package/dist/adapters/mobile/release-artifact.js +295 -0
  19. package/dist/adapters.js +78 -6
  20. package/dist/app-lifecycle.js +4 -3
  21. package/dist/command-contract.js +16 -0
  22. package/dist/commands/call.js +6 -2
  23. package/dist/commands/launch/extension.js +74 -9
  24. package/dist/commands/launch/index.js +3 -0
  25. package/dist/commands/launch/mobile.js +2 -0
  26. package/dist/commands/provision.js +2 -0
  27. package/dist/commands/run-engine.js +109 -14
  28. package/dist/commands/run.js +4 -2
  29. package/dist/commands/runtime-launch.js +178 -10
  30. package/dist/heal-bounds.js +1 -1
  31. package/dist/live-adapter-contract.js +3 -1
  32. package/dist/metamask-action-validation.js +47 -1
  33. package/dist/mm-harness-cli.js +31 -1
  34. package/dist/recipe-security.js +40 -0
  35. package/dist/run-diagnostics.js +1 -1
  36. package/dist/runner.js +3 -0
  37. package/docs/RELEASE-QA-CAPABILITY-MAP.md +125 -0
  38. package/library/actions/extension/assets/finish_send.mjs +128 -0
  39. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  40. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  41. package/library/actions/extension/assets/read_details.mjs +57 -0
  42. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  43. package/library/actions/extension/assets/review_send.mjs +23 -0
  44. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  45. package/library/actions/extension/assets/send.mjs +104 -0
  46. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  47. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  48. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  49. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  50. package/library/actions/extension/networks/add_custom.mjs +123 -0
  51. package/library/actions/extension/networks/custom_network.mjs +103 -0
  52. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  53. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  54. package/library/actions/extension/perps/close_orders.mjs +1 -0
  55. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  56. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  57. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  58. package/library/actions/extension/perps/open_balance_action.mjs +58 -0
  59. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  60. package/library/actions/extension/perps/perps.mjs +196 -2
  61. package/library/actions/extension/perps/place_order.mjs +5 -2
  62. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  63. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  64. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  65. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  66. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  67. package/library/actions/extension/perps/select_market_filter.mjs +71 -0
  68. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  69. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  70. package/library/actions/extension/platform/cdp.mjs +109 -3
  71. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  72. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  73. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  74. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  75. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  76. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  77. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  78. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  79. package/library/actions/extension/ui/navigate.mjs +264 -8
  80. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  81. package/library/actions/extension/wallet/import.mjs +13 -46
  82. package/library/actions/extension/wallet/lock.mjs +35 -0
  83. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  84. package/library/actions/extension/wallet/select_account.mjs +71 -7
  85. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  86. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  87. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  88. package/library/actions/mobile/platform/observe-ui.mjs +86 -2
  89. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  90. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  91. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  92. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  93. package/library/actions/mobile/ui/native-navigation.mjs +265 -0
  94. package/library/actions/mobile/ui/navigate.mjs +10 -1
  95. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  96. package/library/actions/mobile/wallet/import.mjs +71 -2
  97. package/library/actions/mobile/wallet/native-ui.mjs +512 -0
  98. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  99. package/library/actions/mobile/wallet/reset.mjs +17 -4
  100. package/library/actions/shared/assets/visible-state.mjs +176 -0
  101. package/library/actions/shared/networks/visible-state.mjs +70 -0
  102. package/library/actions/shared/perps/visible-state.mjs +354 -0
  103. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  104. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  105. package/library/actions/shared/ui/locators.mjs +7 -0
  106. package/library/manifests/extension.action-manifest.json +1366 -184
  107. package/library/manifests/mobile.action-manifest.json +235 -1
  108. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  109. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  110. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  111. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  112. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  113. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  114. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  115. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  116. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  117. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  118. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  119. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  120. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  121. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  122. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  123. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  124. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  125. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  126. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  127. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  128. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  129. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  130. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  131. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  132. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  133. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  134. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  135. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  136. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  137. package/library/recipes/wallet/import.recipe.json +20 -1
  138. package/library/recipes/wallet/reset-import.recipe.json +20 -1
  139. package/package.json +6 -5
  140. package/scripts/validate-release-report.mjs +319 -0
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
3
+ "title": "Swap release quote",
4
+ "description": "Selects an exact live Swap pair, enters a capped amount, and proves a destination quote through visible product UI without submitting a transaction.",
5
+ "paramsSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "source_symbol": { "type": "string", "default": "ETH" },
10
+ "destination_symbol": { "type": "string", "default": "WETH" },
11
+ "source_chain": { "type": "string", "default": "eip155:1" },
12
+ "destination_chain": { "type": "string", "default": "eip155:1" },
13
+ "source_query": { "type": "string", "default": "ETH" },
14
+ "destination_query": { "type": "string", "default": "WETH" },
15
+ "source_asset_id": { "type": "string", "default": "" },
16
+ "destination_asset_id": { "type": "string", "default": "" },
17
+ "amount": { "type": "number", "minimum": 0.00000001, "default": 0.0001 },
18
+ "timeout_ms": { "type": "integer", "minimum": 1000, "default": 60000 }
19
+ }
20
+ },
21
+ "workflow": {
22
+ "entry": "open-swap",
23
+ "nodes": {
24
+ "open-swap": {
25
+ "action": "ui.navigate",
26
+ "page": "swap",
27
+ "timeout_ms": "{{params.timeout_ms}}",
28
+ "intent": "Open Swap through visible product navigation",
29
+ "next": "select-assets"
30
+ },
31
+ "select-assets": {
32
+ "action": "metamask.swap_bridge.select_assets",
33
+ "source_symbol": "{{params.source_symbol}}",
34
+ "destination_symbol": "{{params.destination_symbol}}",
35
+ "source_chain": "{{params.source_chain}}",
36
+ "destination_chain": "{{params.destination_chain}}",
37
+ "source_query": "{{params.source_query}}",
38
+ "destination_query": "{{params.destination_query}}",
39
+ "source_asset_id": "{{params.source_asset_id}}",
40
+ "destination_asset_id": "{{params.destination_asset_id}}",
41
+ "timeout_ms": "{{params.timeout_ms}}",
42
+ "intent": "Select the exact source and destination assets",
43
+ "next": "enter-amount"
44
+ },
45
+ "enter-amount": {
46
+ "action": "metamask.swap_bridge.set_amount",
47
+ "amount": "{{params.amount}}",
48
+ "wait_for_quote": true,
49
+ "timeout_ms": "{{params.timeout_ms}}",
50
+ "intent": "Enter the capped amount and wait for a live quote",
51
+ "next": "assert-quote"
52
+ },
53
+ "assert-quote": {
54
+ "action": "metamask.swap_bridge.read_visible_state",
55
+ "source_symbol": "{{params.source_symbol}}",
56
+ "destination_symbol": "{{params.destination_symbol}}",
57
+ "require_amount_inputs": true,
58
+ "require_settings": true,
59
+ "intent": "Prove the selected pair and quoted destination amount remain visible",
60
+ "next": "capture-quote"
61
+ },
62
+ "capture-quote": {
63
+ "action": "ui.screenshot",
64
+ "path": "screenshots/swap-quote.png",
65
+ "label": "Swap release quote",
66
+ "intent": "Preserve reviewer-visible proof of the live quote",
67
+ "next": "done"
68
+ },
69
+ "done": { "action": "end", "status": "pass" }
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
3
+ "title": "Swap release surface",
4
+ "description": "Opens Swap through visible product navigation and proves both token selectors, amount inputs, and settings are available without development-only bridges.",
5
+ "paramsSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "source_symbol": { "type": "string", "default": "" },
10
+ "destination_symbol": { "type": "string", "default": "" },
11
+ "timeout_ms": { "type": "integer", "minimum": 1000, "default": 45000 }
12
+ }
13
+ },
14
+ "workflow": {
15
+ "entry": "open-swap",
16
+ "nodes": {
17
+ "open-swap": {
18
+ "action": "ui.navigate",
19
+ "page": "swap",
20
+ "timeout_ms": "{{params.timeout_ms}}",
21
+ "intent": "Open Swap through visible product navigation",
22
+ "next": "assert-swap"
23
+ },
24
+ "assert-swap": {
25
+ "action": "metamask.swap_bridge.read_visible_state",
26
+ "source_symbol": "{{params.source_symbol}}",
27
+ "destination_symbol": "{{params.destination_symbol}}",
28
+ "require_amount_inputs": true,
29
+ "require_settings": true,
30
+ "intent": "Prove the visible Swap inputs, token selectors, and settings control",
31
+ "next": "capture-swap"
32
+ },
33
+ "capture-swap": {
34
+ "action": "ui.screenshot",
35
+ "path": "screenshots/swap-surface.png",
36
+ "label": "Swap release surface",
37
+ "intent": "Preserve reviewer-visible proof of the Swap surface",
38
+ "next": "done"
39
+ },
40
+ "done": { "action": "end", "status": "pass" }
41
+ }
42
+ }
43
+ }
@@ -33,6 +33,22 @@
33
33
  "default": false,
34
34
  "description": "Enable MetaMetrics during visible onboarding; defaults off."
35
35
  },
36
+ "biometrics": {
37
+ "type": "boolean",
38
+ "enum": [false],
39
+ "default": false,
40
+ "description": "Keep Mobile biometric authentication disabled for unattended validation."
41
+ },
42
+ "notifications": {
43
+ "type": "boolean",
44
+ "default": false,
45
+ "description": "Enable Mobile push notifications during onboarding; defaults off."
46
+ },
47
+ "skip_wallet_tour": {
48
+ "type": "boolean",
49
+ "default": true,
50
+ "description": "Skip the Mobile wallet-home tour so the recipe ends at a usable wallet."
51
+ },
36
52
  "interests": {
37
53
  "type": "array",
38
54
  "items": {
@@ -44,7 +60,7 @@
44
60
  },
45
61
  "timeout_ms": {
46
62
  "type": "number",
47
- "default": 120000,
63
+ "default": 300000,
48
64
  "description": "Maximum time for each onboarding transition and final wallet readiness."
49
65
  }
50
66
  }
@@ -64,6 +80,9 @@
64
80
  "srp_env": "{{params.srp_env}}",
65
81
  "password_env": "{{params.password_env}}",
66
82
  "metametrics": "{{params.metametrics}}",
83
+ "biometrics": "{{params.biometrics}}",
84
+ "notifications": "{{params.notifications}}",
85
+ "skip_wallet_tour": "{{params.skip_wallet_tour}}",
67
86
  "interests": "{{params.interests}}",
68
87
  "timeout_ms": "{{params.timeout_ms}}",
69
88
  "intent": "Reuse the fixture-backed wallet or import it through the visible onboarding flow",
@@ -27,6 +27,22 @@
27
27
  "default": false,
28
28
  "description": "Enable MetaMetrics during visible onboarding; defaults off."
29
29
  },
30
+ "biometrics": {
31
+ "type": "boolean",
32
+ "enum": [false],
33
+ "default": false,
34
+ "description": "Enable Mobile biometric authentication during onboarding; defaults off for unattended validation."
35
+ },
36
+ "notifications": {
37
+ "type": "boolean",
38
+ "default": false,
39
+ "description": "Enable Mobile push notifications during onboarding; defaults off."
40
+ },
41
+ "skip_wallet_tour": {
42
+ "type": "boolean",
43
+ "default": true,
44
+ "description": "Skip the Mobile wallet-home tour so the recipe ends at a usable wallet."
45
+ },
30
46
  "interests": {
31
47
  "type": "array",
32
48
  "items": {
@@ -38,7 +54,7 @@
38
54
  },
39
55
  "timeout_ms": {
40
56
  "type": "number",
41
- "default": 120000,
57
+ "default": 300000,
42
58
  "description": "Maximum time for each reset/import transition and final wallet readiness."
43
59
  }
44
60
  }
@@ -69,6 +85,9 @@
69
85
  "srp_env": "{{params.srp_env}}",
70
86
  "password_env": "{{params.password_env}}",
71
87
  "metametrics": "{{params.metametrics}}",
88
+ "biometrics": "{{params.biometrics}}",
89
+ "notifications": "{{params.notifications}}",
90
+ "skip_wallet_tour": "{{params.skip_wallet_tour}}",
72
91
  "interests": "{{params.interests}}",
73
92
  "timeout_ms": "{{params.timeout_ms}}",
74
93
  "intent": "Import the primary mnemonic through visible onboarding UI",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -20,11 +20,11 @@
20
20
  "check:syntax": "find . -name '*.mjs' -print0 | xargs -0 -n1 node --check"
21
21
  },
22
22
  "dependencies": {
23
- "@farmslot/agent-runtime": "0.7.0",
24
- "@farmslot/expo-recipe": "0.7.1",
23
+ "@farmslot/agent-runtime": "0.8.1",
24
+ "@farmslot/expo-recipe": "0.8.1",
25
25
  "@farmslot/handoff": "^0.3.1",
26
- "@farmslot/protocol": "0.18.0",
27
- "@farmslot/recipe-harness": "0.14.0",
26
+ "@farmslot/protocol": "0.21.0",
27
+ "@farmslot/recipe-harness": "0.15.0",
28
28
  "agent-device": "0.19.3",
29
29
  "commander": "^12.0.0",
30
30
  "es-module-lexer": "2.3.1",
@@ -69,6 +69,7 @@
69
69
  "scripts/install-completions.sh",
70
70
  "scripts/site-contrast.mjs",
71
71
  "scripts/validate-human-outcomes.mjs",
72
+ "scripts/validate-release-report.mjs",
72
73
  "!scripts/README.md",
73
74
  "docs",
74
75
  "README.md",
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { validateRecipeArtifactPackage } from '@farmslot/protocol';
5
+
6
+ const options = parseArgs(process.argv.slice(2));
7
+ const inventoryPath = path.resolve(options.inventory);
8
+ const verdictsPath = path.resolve(options.verdicts);
9
+ const inventory = readJson(inventoryPath, 'inventory');
10
+ const verdicts = readJson(verdictsPath, 'verdicts');
11
+ const errors = [];
12
+ const allowedStatuses = new Set([
13
+ 'PASS',
14
+ 'FAIL',
15
+ 'BLOCKED_FUNDS',
16
+ 'BLOCKED_COST',
17
+ 'BLOCKED_ENVIRONMENT',
18
+ ]);
19
+
20
+ const inventoryCases = Array.isArray(inventory.cases) ? inventory.cases : [];
21
+ const verdictCases = Array.isArray(verdicts.cases) ? verdicts.cases : [];
22
+ for (const entry of inventoryCases) {
23
+ if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id.trim().length === 0) {
24
+ errors.push('every inventory case must include a non-empty string ID');
25
+ }
26
+ }
27
+ for (const entry of verdictCases) {
28
+ if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id.trim().length === 0) {
29
+ errors.push('every verdict case must include a non-empty string ID');
30
+ }
31
+ }
32
+ const inventoryIds = inventoryCases.map((entry) => entry?.id).filter((id) => typeof id === 'string' && id.trim().length > 0);
33
+ const verdictIds = verdictCases.map((entry) => entry?.id).filter((id) => typeof id === 'string' && id.trim().length > 0);
34
+ const duplicateInventoryIds = duplicates(inventoryIds);
35
+ const duplicateVerdictIds = duplicates(verdictIds);
36
+ if (inventory.schemaVersion !== 1) errors.push('inventory.schemaVersion must be 1');
37
+ if (verdicts.schemaVersion !== 1) errors.push('verdicts.schemaVersion must be 1');
38
+ if (inventoryCases.length === 0) errors.push('inventory.cases must not be empty');
39
+ if (duplicateInventoryIds.length > 0) errors.push(`duplicate inventory case IDs: ${duplicateInventoryIds.join(', ')}`);
40
+ if (duplicateVerdictIds.length > 0) errors.push(`duplicate verdict case IDs: ${duplicateVerdictIds.join(', ')}`);
41
+
42
+ const inventoryIdSet = new Set(inventoryIds);
43
+ const verdictIdSet = new Set(verdictIds);
44
+ const missing = inventoryIds.filter((id) => !verdictIdSet.has(id));
45
+ const unknown = verdictIds.filter((id) => !inventoryIdSet.has(id));
46
+ if (missing.length > 0) errors.push(`missing case verdicts: ${missing.join(', ')}`);
47
+ if (unknown.length > 0) errors.push(`unknown case verdicts: ${unknown.join(', ')}`);
48
+ if (!Array.isArray(verdicts.remainingCases) || verdicts.remainingCases.length !== 0) {
49
+ errors.push('verdicts.remainingCases must be an empty array after terminal case coverage');
50
+ }
51
+
52
+ const evidenceRoot = path.resolve(verdicts.evidenceRoot ?? path.dirname(verdictsPath));
53
+ let canonicalEvidenceRoot = evidenceRoot;
54
+ try {
55
+ canonicalEvidenceRoot = fs.realpathSync(evidenceRoot);
56
+ } catch {
57
+ errors.push(`evidenceRoot does not exist: ${evidenceRoot}`);
58
+ }
59
+ for (const entry of verdictCases) {
60
+ if (!entry || typeof entry !== 'object') {
61
+ errors.push('every verdict case must be an object');
62
+ continue;
63
+ }
64
+ if (!allowedStatuses.has(entry.status)) {
65
+ errors.push(`${entry.id ?? '<missing-id>'} has unsupported status ${JSON.stringify(entry.status)}`);
66
+ }
67
+ if (typeof entry.summary !== 'string' || entry.summary.trim().length === 0) {
68
+ errors.push(`${entry.id ?? '<missing-id>'} must include a non-empty summary`);
69
+ }
70
+ if (!Array.isArray(entry.evidence) || entry.evidence.length === 0) {
71
+ errors.push(`${entry.id ?? '<missing-id>'} must include evidence`);
72
+ continue;
73
+ }
74
+ for (const evidence of entry.evidence) {
75
+ if (typeof evidence !== 'string' || evidence.length === 0) {
76
+ errors.push(`${entry.id ?? '<missing-id>'} contains invalid evidence`);
77
+ continue;
78
+ }
79
+ const resolved = path.isAbsolute(evidence) ? path.resolve(evidence) : path.resolve(evidenceRoot, evidence);
80
+ if (resolved !== evidenceRoot && !resolved.startsWith(`${evidenceRoot}${path.sep}`)) {
81
+ errors.push(`${entry.id ?? '<missing-id>'} evidence escapes evidenceRoot: ${evidence}`);
82
+ continue;
83
+ }
84
+ if (!fs.existsSync(resolved)) {
85
+ errors.push(`${entry.id ?? '<missing-id>'} evidence does not exist: ${resolved}`);
86
+ continue;
87
+ }
88
+ const stat = fs.lstatSync(resolved);
89
+ if (stat.isSymbolicLink()) {
90
+ errors.push(`${entry.id ?? '<missing-id>'} evidence must not be a symbolic link: ${resolved}`);
91
+ continue;
92
+ }
93
+ const canonicalResolved = fs.realpathSync(resolved);
94
+ if (canonicalResolved !== canonicalEvidenceRoot && !canonicalResolved.startsWith(`${canonicalEvidenceRoot}${path.sep}`)) {
95
+ errors.push(`${entry.id ?? '<missing-id>'} evidence escapes evidenceRoot through a linked path: ${evidence}`);
96
+ continue;
97
+ }
98
+ if (stat.isDirectory()) {
99
+ const documents = {};
100
+ const requiredDocuments = [
101
+ 'summary.json',
102
+ 'trace.json',
103
+ 'artifact-manifest.json',
104
+ 'recipe.json',
105
+ 'recipe-resolution.json',
106
+ ];
107
+ for (const required of requiredDocuments) {
108
+ const requiredPath = path.join(resolved, required);
109
+ if (!fs.existsSync(requiredPath)) {
110
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory is missing ${required}: ${resolved}`);
111
+ continue;
112
+ }
113
+ const requiredStat = fs.lstatSync(requiredPath);
114
+ if (requiredStat.isSymbolicLink() || !requiredStat.isFile()) {
115
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory ${required} must be a regular file: ${resolved}`);
116
+ continue;
117
+ }
118
+ let document;
119
+ try {
120
+ document = JSON.parse(fs.readFileSync(requiredPath, 'utf8'));
121
+ } catch {
122
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory ${required} must contain valid JSON: ${resolved}`);
123
+ continue;
124
+ }
125
+ documents[required] = document;
126
+ }
127
+ if (documents['summary.json'] && entry.status === 'PASS' && documents['summary.json'].status !== 'pass') {
128
+ errors.push(`${entry.id ?? '<missing-id>'} PASS evidence summary.json must have status pass: ${resolved}`);
129
+ }
130
+ if (requiredDocuments.every((required) => required in documents)) {
131
+ const resolvedRecipes = readResolvedRecipes(
132
+ resolved,
133
+ documents['recipe-resolution.json'],
134
+ entry.id ?? '<missing-id>',
135
+ errors,
136
+ );
137
+ const validation = validateRecipeArtifactPackage({
138
+ summary: documents['summary.json'],
139
+ trace: documents['trace.json'],
140
+ manifest: documents['artifact-manifest.json'],
141
+ recipe: documents['recipe.json'],
142
+ recipeResolution: documents['recipe-resolution.json'],
143
+ resolvedRecipes,
144
+ artifactPaths: listEvidenceFiles(resolved),
145
+ });
146
+ if (validation.status !== 'valid') {
147
+ const codes = validation.findings
148
+ .filter((finding) => finding.severity === 'error')
149
+ .map((finding) => finding.code)
150
+ .join(', ');
151
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory is not a valid Recipe Protocol artifact package: ${resolved} (${codes})`);
152
+ }
153
+ }
154
+ } else if (!stat.isFile()) {
155
+ errors.push(`${entry.id ?? '<missing-id>'} evidence must be a file or run directory: ${resolved}`);
156
+ }
157
+ }
158
+ }
159
+
160
+ const counts = Object.fromEntries([...allowedStatuses].map((status) => [
161
+ status,
162
+ verdictCases.filter((entry) => entry?.status === status).length,
163
+ ]));
164
+ const result = {
165
+ schemaVersion: 1,
166
+ command: 'validate-release-report',
167
+ status: errors.length === 0 ? 'pass' : 'fail',
168
+ releaseStatus: counts.FAIL > 0 ? 'fail' : Object.entries(counts).some(([status, count]) => status.startsWith('BLOCKED_') && count > 0) ? 'blocked' : 'pass',
169
+ inventoryPath,
170
+ verdictsPath,
171
+ evidenceRoot,
172
+ expectedCases: inventoryIds.length,
173
+ coveredCases: verdictIds.filter((id) => inventoryIdSet.has(id)).length,
174
+ counts,
175
+ missingCases: missing,
176
+ unknownCases: unknown,
177
+ errors,
178
+ };
179
+ if (errors.length === 0 && options.outputDir) {
180
+ const outputDir = path.resolve(options.outputDir);
181
+ fs.mkdirSync(outputDir, { recursive: true });
182
+ const metadata = {
183
+ schemaVersion: 1,
184
+ release: verdicts.release ?? null,
185
+ platform: verdicts.platform ?? null,
186
+ domain: verdicts.domain ?? null,
187
+ artifactSha256: verdicts.artifactSha256 ?? null,
188
+ releaseStatus: result.releaseStatus,
189
+ total: inventoryIds.length,
190
+ counts,
191
+ remaining_cases: [],
192
+ };
193
+ const quality = {
194
+ schemaVersion: 1,
195
+ status: 'pass',
196
+ canonicalCaseCoverage: true,
197
+ terminalVerdicts: true,
198
+ evidenceComplete: true,
199
+ summariesComplete: true,
200
+ remainingCasesComplete: true,
201
+ };
202
+ fs.writeFileSync(path.join(outputDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`);
203
+ fs.writeFileSync(path.join(outputDir, 'report-quality.json'), `${JSON.stringify(quality, null, 2)}\n`);
204
+ fs.writeFileSync(path.join(outputDir, 'report.md'), renderMarkdown(verdicts, inventoryCases, counts, result.releaseStatus));
205
+ result.outputDir = outputDir;
206
+ }
207
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
208
+ process.exit(errors.length === 0 ? 0 : 1);
209
+
210
+ function readJson(file, label) {
211
+ try {
212
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
213
+ } catch (error) {
214
+ console.error(`validate-release-report: could not read ${label} ${file}: ${error instanceof Error ? error.message : String(error)}`);
215
+ process.exit(2);
216
+ }
217
+ }
218
+
219
+ function duplicates(values) {
220
+ const seen = new Set();
221
+ const repeated = new Set();
222
+ for (const value of values) {
223
+ if (seen.has(value)) repeated.add(value);
224
+ seen.add(value);
225
+ }
226
+ return [...repeated];
227
+ }
228
+
229
+ function listEvidenceFiles(root) {
230
+ const files = [];
231
+ const pending = [{ directory: root, prefix: '' }];
232
+ while (pending.length > 0) {
233
+ const { directory, prefix } = pending.pop();
234
+ for (const name of fs.readdirSync(directory)) {
235
+ const absolute = path.join(directory, name);
236
+ const relative = prefix ? `${prefix}/${name}` : name;
237
+ const stat = fs.lstatSync(absolute);
238
+ if (stat.isSymbolicLink()) continue;
239
+ if (stat.isDirectory()) pending.push({ directory: absolute, prefix: relative });
240
+ else if (stat.isFile()) files.push(relative);
241
+ }
242
+ }
243
+ return files;
244
+ }
245
+
246
+ function readResolvedRecipes(root, resolution, caseId, validationErrors) {
247
+ const recipes = {};
248
+ if (!resolution || typeof resolution !== 'object' || !Array.isArray(resolution.dependencies)) return recipes;
249
+ const canonicalRoot = fs.realpathSync(root);
250
+ for (const dependency of resolution.dependencies) {
251
+ const digest = dependency?.digest;
252
+ const artifact = dependency?.artifact;
253
+ if (
254
+ typeof digest !== 'string' ||
255
+ typeof artifact !== 'string' ||
256
+ !/^resolved-recipes\/sha256-[0-9a-f]{64}\.json$/u.test(artifact)
257
+ ) continue;
258
+ const artifactPath = path.join(root, ...artifact.split('/'));
259
+ if (!fs.existsSync(artifactPath)) continue;
260
+ const canonicalArtifact = fs.realpathSync(artifactPath);
261
+ if (!canonicalArtifact.startsWith(`${canonicalRoot}${path.sep}`)) continue;
262
+ const stat = fs.lstatSync(artifactPath);
263
+ if (stat.isSymbolicLink() || !stat.isFile()) continue;
264
+ try {
265
+ recipes[digest] = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
266
+ } catch {
267
+ validationErrors.push(`${caseId} evidence directory resolved recipe must contain valid JSON: ${artifactPath}`);
268
+ }
269
+ }
270
+ return recipes;
271
+ }
272
+
273
+ function renderMarkdown(verdicts, inventoryCases, counts, releaseStatus) {
274
+ const byId = new Map(verdicts.cases.map((entry) => [entry.id, entry]));
275
+ const lines = [
276
+ `# ${verdicts.platform ?? 'Release'} ${verdicts.release ?? ''} ${verdicts.domain ?? ''} validation`,
277
+ '',
278
+ `Release status: **${releaseStatus.toUpperCase()}**`,
279
+ '',
280
+ `Artifact SHA-256: \`${verdicts.artifactSha256 ?? 'not-recorded'}\``,
281
+ '',
282
+ `Cases: ${inventoryCases.length} total; ${counts.PASS} pass; ${counts.FAIL} fail; ${counts.BLOCKED_ENVIRONMENT + counts.BLOCKED_FUNDS + counts.BLOCKED_COST} blocked.`,
283
+ '',
284
+ '| Case | Verdict | Result |',
285
+ '| --- | --- | --- |',
286
+ ];
287
+ for (const inventoryCase of inventoryCases) {
288
+ const verdict = byId.get(inventoryCase.id);
289
+ const evidence = verdict.evidence.map((entry) => `\`${entry}\``).join(', ');
290
+ lines.push(`| ${inventoryCase.id} | ${verdict.status} | ${escapeTable(verdict.summary)} Evidence: ${evidence} |`);
291
+ }
292
+ lines.push('');
293
+ return `${lines.join('\n')}\n`;
294
+ }
295
+
296
+ function escapeTable(value) {
297
+ return String(value).replace(/\|/gu, '\\|').replace(/\s+/gu, ' ').trim();
298
+ }
299
+
300
+ function parseArgs(argv) {
301
+ const parsed = {};
302
+ for (let index = 0; index < argv.length; index += 1) {
303
+ const argument = argv[index];
304
+ if (argument === '--inventory' || argument === '--verdicts' || argument === '--output-dir') {
305
+ const value = argv[index + 1];
306
+ if (!value || value.startsWith('--')) usage(`missing value for ${argument}`);
307
+ parsed[argument.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase())] = value;
308
+ index += 1;
309
+ } else usage(`unknown option: ${argument}`);
310
+ }
311
+ if (!parsed.inventory || !parsed.verdicts) usage('both --inventory and --verdicts are required');
312
+ return parsed;
313
+ }
314
+
315
+ function usage(message) {
316
+ if (message) console.error(`validate-release-report: ${message}`);
317
+ console.error('Usage: node scripts/validate-release-report.mjs --inventory <file> --verdicts <file> [--output-dir <dir>]');
318
+ process.exit(2);
319
+ }