@deeeed/metamask-harness 0.37.0 → 0.38.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 (113) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/adapters/extension/artifact-runtime-state.cjs +13 -3
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +2 -0
  5. package/adapters/extension/live.sh +1 -1
  6. package/adapters/extension/readiness.mjs +1 -1
  7. package/adapters/manifest.json +8 -0
  8. package/dist/adapters/extension/runtime-decision.js +4 -1
  9. package/dist/adapters/extension/runtime.js +42 -3
  10. package/dist/adapters/mobile/provision.js +38 -3
  11. package/dist/adapters.js +68 -3
  12. package/dist/app-lifecycle.js +4 -3
  13. package/dist/commands/call.js +4 -1
  14. package/dist/commands/launch/extension.js +21 -5
  15. package/dist/commands/launch/index.js +3 -0
  16. package/dist/commands/run-engine.js +40 -29
  17. package/dist/commands/run.js +1 -1
  18. package/dist/recipe-security.js +40 -0
  19. package/dist/runner.js +3 -0
  20. package/docs/RELEASE-QA-CAPABILITY-MAP.md +116 -141
  21. package/library/actions/extension/assets/finish_send.mjs +128 -0
  22. package/library/actions/extension/assets/import_custom_token.mjs +227 -0
  23. package/library/actions/extension/assets/prepare_send.mjs +53 -0
  24. package/library/actions/extension/assets/read_details.mjs +57 -0
  25. package/library/actions/extension/assets/read_visible_state.mjs +50 -0
  26. package/library/actions/extension/assets/review_send.mjs +23 -0
  27. package/library/actions/extension/assets/select_network_scope.mjs +30 -0
  28. package/library/actions/extension/assets/send.mjs +104 -0
  29. package/library/actions/extension/assets/set_custom_gas.mjs +56 -0
  30. package/library/actions/extension/assets/set_token_visibility.mjs +129 -0
  31. package/library/actions/extension/assets/verify_sorting.mjs +89 -0
  32. package/library/actions/extension/networks/add_chainlist.mjs +94 -0
  33. package/library/actions/extension/networks/add_custom.mjs +123 -0
  34. package/library/actions/extension/networks/custom_network.mjs +103 -0
  35. package/library/actions/extension/networks/read_visible_state.mjs +50 -0
  36. package/library/actions/extension/networks/remove_custom.mjs +68 -0
  37. package/library/actions/extension/perps/close_orders.mjs +1 -0
  38. package/library/actions/extension/perps/close_visible_position.mjs +133 -0
  39. package/library/actions/extension/perps/compare_provider_market.mjs +109 -0
  40. package/library/actions/extension/perps/edit_margin.mjs +82 -0
  41. package/library/actions/extension/perps/open_balance_action.mjs +53 -0
  42. package/library/actions/extension/perps/open_position_action.mjs +80 -0
  43. package/library/actions/extension/perps/perps.mjs +194 -2
  44. package/library/actions/extension/perps/place_order.mjs +5 -2
  45. package/library/actions/extension/perps/read_eligibility.mjs +34 -0
  46. package/library/actions/extension/perps/read_funds_confirmation.mjs +69 -0
  47. package/library/actions/extension/perps/read_visible_state.mjs +98 -0
  48. package/library/actions/extension/perps/select_activity_filter.mjs +44 -0
  49. package/library/actions/extension/perps/select_market_filter.mjs +72 -0
  50. package/library/actions/extension/perps/set_market_favorite.mjs +47 -0
  51. package/library/actions/extension/perps/update_position_tpsl.mjs +106 -0
  52. package/library/actions/extension/platform/cdp.mjs +104 -1
  53. package/library/actions/extension/settings/set_basic_functionality.mjs +58 -0
  54. package/library/actions/extension/swap_bridge/read_visible_state.mjs +71 -0
  55. package/library/actions/extension/swap_bridge/select_assets.mjs +103 -0
  56. package/library/actions/extension/swap_bridge/set_amount.mjs +36 -0
  57. package/library/actions/extension/swap_bridge/set_max_amount.mjs +58 -0
  58. package/library/actions/extension/swap_bridge/set_slippage.mjs +121 -0
  59. package/library/actions/extension/swap_bridge/submit_transaction.mjs +363 -0
  60. package/library/actions/extension/ui/activate_and_observe.mjs +96 -0
  61. package/library/actions/extension/ui/navigate.mjs +264 -8
  62. package/library/actions/extension/wallet/ensure_unlocked.mjs +2 -6
  63. package/library/actions/extension/wallet/lock.mjs +35 -0
  64. package/library/actions/extension/wallet/select_account.mjs +71 -7
  65. package/library/actions/mobile/assets/read_visible_state.mjs +37 -0
  66. package/library/actions/mobile/perps/read_visible_state.mjs +31 -0
  67. package/library/actions/mobile/platform/native-session-name.mjs +5 -0
  68. package/library/actions/mobile/platform/observe-ui.mjs +5 -3
  69. package/library/actions/mobile/swap_bridge/native-session.mjs +45 -0
  70. package/library/actions/mobile/swap_bridge/read_visible_state.mjs +31 -0
  71. package/library/actions/mobile/swap_bridge/select_assets.mjs +94 -0
  72. package/library/actions/mobile/swap_bridge/set_amount.mjs +73 -0
  73. package/library/actions/mobile/ui/native-navigation.mjs +45 -5
  74. package/library/actions/mobile/ui/navigate.mjs +3 -1
  75. package/library/actions/mobile/wallet/ensure_unlocked.mjs +17 -0
  76. package/library/actions/mobile/wallet/native-ui.mjs +21 -2
  77. package/library/actions/shared/assets/visible-state.mjs +176 -0
  78. package/library/actions/shared/networks/visible-state.mjs +70 -0
  79. package/library/actions/shared/perps/visible-state.mjs +354 -0
  80. package/library/actions/shared/swap-bridge/transaction.mjs +54 -0
  81. package/library/actions/shared/swap-bridge/visible-state.mjs +97 -0
  82. package/library/manifests/extension.action-manifest.json +1255 -189
  83. package/library/manifests/mobile.action-manifest.json +219 -1
  84. package/library/recipes/assets/release-custom-gas-send.recipe.json +88 -0
  85. package/library/recipes/assets/release-custom-network-token-persistence.recipe.json +106 -0
  86. package/library/recipes/assets/release-native-max-cancel.recipe.json +70 -0
  87. package/library/recipes/assets/release-native-max-send.recipe.json +78 -0
  88. package/library/recipes/assets/release-own-account-send.recipe.json +80 -0
  89. package/library/recipes/assets/release-token-address-import.recipe.json +47 -0
  90. package/library/recipes/assets/release-token-details.recipe.json +48 -0
  91. package/library/recipes/assets/release-token-hide-readd.recipe.json +55 -0
  92. package/library/recipes/assets/release-token-list.recipe.json +75 -0
  93. package/library/recipes/assets/release-token-persistence.recipe.json +73 -0
  94. package/library/recipes/assets/release-token-search-import.recipe.json +40 -0
  95. package/library/recipes/assets/release-token-sorting.recipe.json +29 -0
  96. package/library/recipes/extension/networks/release-balance-stability.recipe.json +51 -0
  97. package/library/recipes/extension/perps/release-live-limit-order.recipe.json +104 -0
  98. package/library/recipes/extension/perps/release-order-entry.recipe.json +155 -0
  99. package/library/recipes/extension/perps/release-order-validation.recipe.json +220 -0
  100. package/library/recipes/perps/release-activity-filters.recipe.json +51 -0
  101. package/library/recipes/perps/release-funds-flows.recipe.json +76 -0
  102. package/library/recipes/perps/release-live-market-order.recipe.json +122 -0
  103. package/library/recipes/perps/release-market-details.recipe.json +83 -0
  104. package/library/recipes/perps/release-market-search.recipe.json +79 -0
  105. package/library/recipes/perps/release-watchlist.recipe.json +99 -0
  106. package/library/recipes/swap-bridge/release-custom-slippage.recipe.json +90 -0
  107. package/library/recipes/swap-bridge/release-live-max-swap.recipe.json +99 -0
  108. package/library/recipes/swap-bridge/release-live-swap.recipe.json +101 -0
  109. package/library/recipes/swap-bridge/release-max-quote.recipe.json +72 -0
  110. package/library/recipes/swap-bridge/release-quote.recipe.json +72 -0
  111. package/library/recipes/swap-bridge/release-surface.recipe.json +43 -0
  112. package/package.json +6 -5
  113. package/scripts/validate-release-report.mjs +333 -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.37.0",
3
+ "version": "0.38.1",
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,333 @@
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
+ const requiredDocument = readRegularJsonFile(requiredPath);
110
+ if (requiredDocument.status === 'missing') {
111
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory is missing ${required}: ${resolved}`);
112
+ continue;
113
+ }
114
+ if (requiredDocument.status === 'unsafe') {
115
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory ${required} must be a regular file: ${resolved}`);
116
+ continue;
117
+ }
118
+ if (requiredDocument.status === 'invalid') {
119
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory ${required} must contain valid JSON: ${resolved}`);
120
+ continue;
121
+ }
122
+ documents[required] = requiredDocument.value;
123
+ }
124
+ if (documents['summary.json'] && entry.status === 'PASS' && documents['summary.json'].status !== 'pass') {
125
+ errors.push(`${entry.id ?? '<missing-id>'} PASS evidence summary.json must have status pass: ${resolved}`);
126
+ }
127
+ if (requiredDocuments.every((required) => required in documents)) {
128
+ const resolvedRecipes = readResolvedRecipes(
129
+ resolved,
130
+ documents['recipe-resolution.json'],
131
+ entry.id ?? '<missing-id>',
132
+ errors,
133
+ );
134
+ const validation = validateRecipeArtifactPackage({
135
+ summary: documents['summary.json'],
136
+ trace: documents['trace.json'],
137
+ manifest: documents['artifact-manifest.json'],
138
+ recipe: documents['recipe.json'],
139
+ recipeResolution: documents['recipe-resolution.json'],
140
+ resolvedRecipes,
141
+ artifactPaths: listEvidenceFiles(resolved),
142
+ });
143
+ if (validation.status !== 'valid') {
144
+ const codes = validation.findings
145
+ .filter((finding) => finding.severity === 'error')
146
+ .map((finding) => finding.code)
147
+ .join(', ');
148
+ errors.push(`${entry.id ?? '<missing-id>'} evidence directory is not a valid Recipe Protocol artifact package: ${resolved} (${codes})`);
149
+ }
150
+ }
151
+ } else if (!stat.isFile()) {
152
+ errors.push(`${entry.id ?? '<missing-id>'} evidence must be a file or run directory: ${resolved}`);
153
+ }
154
+ }
155
+ }
156
+
157
+ const counts = Object.fromEntries([...allowedStatuses].map((status) => [
158
+ status,
159
+ verdictCases.filter((entry) => entry?.status === status).length,
160
+ ]));
161
+ const result = {
162
+ schemaVersion: 1,
163
+ command: 'validate-release-report',
164
+ status: errors.length === 0 ? 'pass' : 'fail',
165
+ releaseStatus: counts.FAIL > 0 ? 'fail' : Object.entries(counts).some(([status, count]) => status.startsWith('BLOCKED_') && count > 0) ? 'blocked' : 'pass',
166
+ inventoryPath,
167
+ verdictsPath,
168
+ evidenceRoot,
169
+ expectedCases: inventoryIds.length,
170
+ coveredCases: verdictIds.filter((id) => inventoryIdSet.has(id)).length,
171
+ counts,
172
+ missingCases: missing,
173
+ unknownCases: unknown,
174
+ errors,
175
+ };
176
+ if (errors.length === 0 && options.outputDir) {
177
+ const outputDir = path.resolve(options.outputDir);
178
+ fs.mkdirSync(outputDir, { recursive: true });
179
+ const metadata = {
180
+ schemaVersion: 1,
181
+ release: verdicts.release ?? null,
182
+ platform: verdicts.platform ?? null,
183
+ domain: verdicts.domain ?? null,
184
+ artifactSha256: verdicts.artifactSha256 ?? null,
185
+ releaseStatus: result.releaseStatus,
186
+ total: inventoryIds.length,
187
+ counts,
188
+ remaining_cases: [],
189
+ };
190
+ const quality = {
191
+ schemaVersion: 1,
192
+ status: 'pass',
193
+ canonicalCaseCoverage: true,
194
+ terminalVerdicts: true,
195
+ evidenceComplete: true,
196
+ summariesComplete: true,
197
+ remainingCasesComplete: true,
198
+ };
199
+ fs.writeFileSync(path.join(outputDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`);
200
+ fs.writeFileSync(path.join(outputDir, 'report-quality.json'), `${JSON.stringify(quality, null, 2)}\n`);
201
+ fs.writeFileSync(path.join(outputDir, 'report.md'), renderMarkdown(verdicts, inventoryCases, counts, result.releaseStatus));
202
+ result.outputDir = outputDir;
203
+ }
204
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
205
+ process.exit(errors.length === 0 ? 0 : 1);
206
+
207
+ function readJson(file, label) {
208
+ try {
209
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
210
+ } catch (error) {
211
+ console.error(`validate-release-report: could not read ${label} ${file}: ${error instanceof Error ? error.message : String(error)}`);
212
+ process.exit(2);
213
+ }
214
+ }
215
+
216
+ function readRegularJsonFile(file) {
217
+ let descriptor;
218
+ try {
219
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
220
+ if (!fs.fstatSync(descriptor).isFile()) return { status: 'unsafe' };
221
+ const contents = fs.readFileSync(descriptor, 'utf8');
222
+ try {
223
+ return { status: 'ok', value: JSON.parse(contents) };
224
+ } catch {
225
+ return { status: 'invalid' };
226
+ }
227
+ } catch (error) {
228
+ return { status: error?.code === 'ENOENT' ? 'missing' : 'unsafe' };
229
+ } finally {
230
+ if (descriptor !== undefined) fs.closeSync(descriptor);
231
+ }
232
+ }
233
+
234
+ function duplicates(values) {
235
+ const seen = new Set();
236
+ const repeated = new Set();
237
+ for (const value of values) {
238
+ if (seen.has(value)) repeated.add(value);
239
+ seen.add(value);
240
+ }
241
+ return [...repeated];
242
+ }
243
+
244
+ function listEvidenceFiles(root) {
245
+ const files = [];
246
+ const pending = [{ directory: root, prefix: '' }];
247
+ while (pending.length > 0) {
248
+ const { directory, prefix } = pending.pop();
249
+ for (const name of fs.readdirSync(directory)) {
250
+ const absolute = path.join(directory, name);
251
+ const relative = prefix ? `${prefix}/${name}` : name;
252
+ const stat = fs.lstatSync(absolute);
253
+ if (stat.isSymbolicLink()) continue;
254
+ if (stat.isDirectory()) pending.push({ directory: absolute, prefix: relative });
255
+ else if (stat.isFile()) files.push(relative);
256
+ }
257
+ }
258
+ return files;
259
+ }
260
+
261
+ function readResolvedRecipes(root, resolution, caseId, validationErrors) {
262
+ const recipes = {};
263
+ if (!resolution || typeof resolution !== 'object' || !Array.isArray(resolution.dependencies)) return recipes;
264
+ const canonicalRoot = fs.realpathSync(root);
265
+ for (const dependency of resolution.dependencies) {
266
+ const digest = dependency?.digest;
267
+ const artifact = dependency?.artifact;
268
+ if (
269
+ typeof digest !== 'string' ||
270
+ typeof artifact !== 'string' ||
271
+ !/^resolved-recipes\/sha256-[0-9a-f]{64}\.json$/u.test(artifact)
272
+ ) continue;
273
+ const artifactPath = path.join(root, ...artifact.split('/'));
274
+ if (!fs.existsSync(artifactPath)) continue;
275
+ const canonicalArtifact = fs.realpathSync(artifactPath);
276
+ if (!canonicalArtifact.startsWith(`${canonicalRoot}${path.sep}`)) continue;
277
+ const recipe = readRegularJsonFile(artifactPath);
278
+ if (recipe.status === 'ok') {
279
+ recipes[digest] = recipe.value;
280
+ } else if (recipe.status === 'invalid') {
281
+ validationErrors.push(`${caseId} evidence directory resolved recipe must contain valid JSON: ${artifactPath}`);
282
+ }
283
+ }
284
+ return recipes;
285
+ }
286
+
287
+ function renderMarkdown(verdicts, inventoryCases, counts, releaseStatus) {
288
+ const byId = new Map(verdicts.cases.map((entry) => [entry.id, entry]));
289
+ const lines = [
290
+ `# ${verdicts.platform ?? 'Release'} ${verdicts.release ?? ''} ${verdicts.domain ?? ''} validation`,
291
+ '',
292
+ `Release status: **${releaseStatus.toUpperCase()}**`,
293
+ '',
294
+ `Artifact SHA-256: \`${verdicts.artifactSha256 ?? 'not-recorded'}\``,
295
+ '',
296
+ `Cases: ${inventoryCases.length} total; ${counts.PASS} pass; ${counts.FAIL} fail; ${counts.BLOCKED_ENVIRONMENT + counts.BLOCKED_FUNDS + counts.BLOCKED_COST} blocked.`,
297
+ '',
298
+ '| Case | Verdict | Result |',
299
+ '| --- | --- | --- |',
300
+ ];
301
+ for (const inventoryCase of inventoryCases) {
302
+ const verdict = byId.get(inventoryCase.id);
303
+ const evidence = verdict.evidence.map((entry) => `\`${entry}\``).join(', ');
304
+ lines.push(`| ${inventoryCase.id} | ${verdict.status} | ${escapeTable(verdict.summary)} Evidence: ${evidence} |`);
305
+ }
306
+ lines.push('');
307
+ return `${lines.join('\n')}\n`;
308
+ }
309
+
310
+ function escapeTable(value) {
311
+ return String(value).replace(/\\/gu, '\\\\').replace(/\|/gu, '\\|').replace(/\s+/gu, ' ').trim();
312
+ }
313
+
314
+ function parseArgs(argv) {
315
+ const parsed = {};
316
+ for (let index = 0; index < argv.length; index += 1) {
317
+ const argument = argv[index];
318
+ if (argument === '--inventory' || argument === '--verdicts' || argument === '--output-dir') {
319
+ const value = argv[index + 1];
320
+ if (!value || value.startsWith('--')) usage(`missing value for ${argument}`);
321
+ parsed[argument.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase())] = value;
322
+ index += 1;
323
+ } else usage(`unknown option: ${argument}`);
324
+ }
325
+ if (!parsed.inventory || !parsed.verdicts) usage('both --inventory and --verdicts are required');
326
+ return parsed;
327
+ }
328
+
329
+ function usage(message) {
330
+ if (message) console.error(`validate-release-report: ${message}`);
331
+ console.error('Usage: node scripts/validate-release-report.mjs --inventory <file> --verdicts <file> [--output-dir <dir>]');
332
+ process.exit(2);
333
+ }