@gatedpath/preflight 0.1.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.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/bip39-words.d.ts +1 -0
- package/dist/bip39-words.js +132 -0
- package/dist/checks/admin.d.ts +24 -0
- package/dist/checks/admin.js +81 -0
- package/dist/checks/balance.d.ts +3 -0
- package/dist/checks/balance.js +39 -0
- package/dist/checks/chain-id.d.ts +2 -0
- package/dist/checks/chain-id.js +11 -0
- package/dist/checks/compiler.d.ts +5 -0
- package/dist/checks/compiler.js +20 -0
- package/dist/checks/deployer.d.ts +14 -0
- package/dist/checks/deployer.js +53 -0
- package/dist/checks/git-secrets.d.ts +14 -0
- package/dist/checks/git-secrets.js +91 -0
- package/dist/checks/slither.d.ts +11 -0
- package/dist/checks/slither.js +119 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +131 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +197 -0
- package/dist/doctor-cli.d.ts +9 -0
- package/dist/doctor-cli.js +76 -0
- package/dist/doctor.d.ts +60 -0
- package/dist/doctor.js +251 -0
- package/dist/dotenv.d.ts +7 -0
- package/dist/dotenv.js +28 -0
- package/dist/gas-cli.d.ts +8 -0
- package/dist/gas-cli.js +100 -0
- package/dist/gas.d.ts +110 -0
- package/dist/gas.js +221 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +20 -0
- package/dist/proc.d.ts +14 -0
- package/dist/proc.js +28 -0
- package/dist/redbelly.d.ts +2 -0
- package/dist/redbelly.js +40 -0
- package/dist/run.d.ts +9 -0
- package/dist/run.js +106 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.js +89 -0
- package/dist/ship-cli.d.ts +8 -0
- package/dist/ship-cli.js +128 -0
- package/dist/ship.d.ts +108 -0
- package/dist/ship.js +383 -0
- package/dist/suites.d.ts +26 -0
- package/dist/suites.js +53 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +3 -0
- package/dist/well-known.d.ts +2 -0
- package/dist/well-known.js +21 -0
- package/package.json +50 -0
package/dist/ship-cli.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// `redbelly ship`: the mainnet gate that writes its own report. Exit 0 when every check passed and
|
|
2
|
+
// deployments/ship-<chain>-<date>.md was written, 1 when a check failed (nothing written), 2 on bad
|
|
3
|
+
// arguments. --json for agents. Never takes a key: --account names a keystore that cast reads.
|
|
4
|
+
import { SHIP_VERSION, renderShipText, runShip, shipReportJson } from './ship.js';
|
|
5
|
+
export const SHIP_HELP = `redbelly ship --chain <151|153> --account <keystore-name> [options]
|
|
6
|
+
|
|
7
|
+
Runs pre-flight (seven checks) with the keystore's address, checks the Slither sidecar, runs
|
|
8
|
+
forge test and the five-state suites, prices the gas report at the chain's base fee and feed
|
|
9
|
+
price, reads the deployer and the admin (isAllowed, Safe 1.4.1, threshold, owners), and writes
|
|
10
|
+
deployments/ship-<chain>-<date>.md with every check and its result, the addresses, the
|
|
11
|
+
constructor arguments and the verify command. Refuses to write when any check fails. The
|
|
12
|
+
deploy script refuses to broadcast on 151 without a report dated today; 153 warns.
|
|
13
|
+
|
|
14
|
+
--chain <151|153> required
|
|
15
|
+
--account <name> required; Foundry keystore name, resolved with cast wallet address
|
|
16
|
+
--password-file <path> passed through to cast (or set ETH_PASSWORD)
|
|
17
|
+
--admin <0x…> the Safe that receives every role (or ADMIN_SAFE in .env); required on 151
|
|
18
|
+
--gas <units> deployment gas for the balance check (default: the gas report's deployment row)
|
|
19
|
+
--verifier <0x…> the dApp's verifier (or VERIFIER in .env); required on 151
|
|
20
|
+
--project <dir> Foundry project or scaffold root (default: current directory)
|
|
21
|
+
--rpc <url> JSON-RPC endpoint (default: the governors RPC for --chain; a fork works)
|
|
22
|
+
--out <path> write the report here instead
|
|
23
|
+
--json print the report as JSON (the file is still written when checks pass)
|
|
24
|
+
--version, --help
|
|
25
|
+
`;
|
|
26
|
+
export function parseShipArgs(argv) {
|
|
27
|
+
const o = { json: false, help: false, version: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
|
|
31
|
+
const next = () => {
|
|
32
|
+
if (inline !== undefined)
|
|
33
|
+
return inline;
|
|
34
|
+
const v = argv[++i];
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
throw new Error(`${flag} needs a value`);
|
|
37
|
+
return v;
|
|
38
|
+
};
|
|
39
|
+
switch (flag) {
|
|
40
|
+
case '--project':
|
|
41
|
+
o.project = next();
|
|
42
|
+
break;
|
|
43
|
+
case '--chain': {
|
|
44
|
+
const c = Number(next());
|
|
45
|
+
if (c !== 151 && c !== 153)
|
|
46
|
+
throw new Error('--chain must be 151 or 153');
|
|
47
|
+
o.chain = c;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case '--account':
|
|
51
|
+
o.account = next();
|
|
52
|
+
break;
|
|
53
|
+
case '--password-file':
|
|
54
|
+
o.passwordFile = next();
|
|
55
|
+
break;
|
|
56
|
+
case '--admin':
|
|
57
|
+
o.admin = next();
|
|
58
|
+
break;
|
|
59
|
+
case '--verifier':
|
|
60
|
+
o.verifier = next();
|
|
61
|
+
break;
|
|
62
|
+
case '--rpc':
|
|
63
|
+
o.rpc = next();
|
|
64
|
+
break;
|
|
65
|
+
case '--out':
|
|
66
|
+
o.out = next();
|
|
67
|
+
break;
|
|
68
|
+
case '--gas': {
|
|
69
|
+
const g = next().replace(/[_,]/g, '');
|
|
70
|
+
if (!/^\d+$/.test(g))
|
|
71
|
+
throw new Error('--gas must be a whole number of gas units');
|
|
72
|
+
o.gas = BigInt(g);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case '--json':
|
|
76
|
+
o.json = true;
|
|
77
|
+
break;
|
|
78
|
+
case '--help':
|
|
79
|
+
case '-h':
|
|
80
|
+
o.help = true;
|
|
81
|
+
break;
|
|
82
|
+
case '--version':
|
|
83
|
+
o.version = true;
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
if (/^(0x)?[0-9a-fA-F]{64}$/.test(a))
|
|
87
|
+
throw new Error('that argument looks like a private key; ship never takes one, use --account <keystore-name>');
|
|
88
|
+
throw new Error(`unknown argument "${a}"\n\n${SHIP_HELP}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!o.help && !o.version) {
|
|
92
|
+
if (!o.chain)
|
|
93
|
+
throw new Error(`--chain 151 or 153 is required\n\n${SHIP_HELP}`);
|
|
94
|
+
if (!o.account)
|
|
95
|
+
throw new Error(`--account <keystore-name> is required: the report carries the deployer's address and its isAllowed reading\n\n${SHIP_HELP}`);
|
|
96
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(o.account))
|
|
97
|
+
throw new Error('--account: keystore names use letters, digits, dot, dash and underscore only');
|
|
98
|
+
}
|
|
99
|
+
return o;
|
|
100
|
+
}
|
|
101
|
+
export async function shipMain(argv = process.argv.slice(2)) {
|
|
102
|
+
let args;
|
|
103
|
+
try {
|
|
104
|
+
args = parseShipArgs(argv);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
process.stderr.write(`${e.message}\n`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
if (args.help) {
|
|
111
|
+
process.stdout.write(SHIP_HELP);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (args.version) {
|
|
115
|
+
process.stdout.write(`${SHIP_VERSION}\n`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const { json, help: _h, version: _v, ...opts } = args;
|
|
119
|
+
try {
|
|
120
|
+
const report = await runShip(opts);
|
|
121
|
+
process.stdout.write(json ? shipReportJson(report) : renderShipText(report));
|
|
122
|
+
return report.ok ? 0 : 1;
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
process.stderr.write(`redbelly ship: ${e.message}\n`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/ship.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AdminReading } from './checks/admin.js';
|
|
2
|
+
import type { GasQuote, PricedRow } from './gas.js';
|
|
3
|
+
import type { SuiteSummary } from './suites.js';
|
|
4
|
+
import type { CheckStatus, PreflightReport } from './types.js';
|
|
5
|
+
export declare const SHIP_VERSION: string;
|
|
6
|
+
export interface ShipOptions {
|
|
7
|
+
/** Foundry project or scaffold root (its contracts/ is used). Default: cwd. */
|
|
8
|
+
project?: string;
|
|
9
|
+
chain: 151 | 153;
|
|
10
|
+
/** Foundry keystore name; cast resolves the address, nothing else is read from it. */
|
|
11
|
+
account: string;
|
|
12
|
+
passwordFile?: string;
|
|
13
|
+
/** The Safe that receives every role. Required on 151; ADMIN_SAFE in .env fills it in. */
|
|
14
|
+
admin?: string;
|
|
15
|
+
/** Deployment gas for the balance check. Default: the gas report's deployment row, then 3,000,000. */
|
|
16
|
+
gas?: number | bigint;
|
|
17
|
+
rpc?: string;
|
|
18
|
+
/** The dApp's verifier. Required on 151; VERIFIER in .env fills it in. */
|
|
19
|
+
verifier?: string;
|
|
20
|
+
/** Where to write. Default: deployments/ship-<chain>-<date>.md under the Foundry project. */
|
|
21
|
+
out?: string;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
}
|
|
24
|
+
export interface ShipCheck {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly status: CheckStatus;
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
readonly data?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface ShipContract {
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly address: string;
|
|
33
|
+
readonly record: string;
|
|
34
|
+
readonly deployer: string | null;
|
|
35
|
+
readonly admin: string | null;
|
|
36
|
+
readonly verifier: string | null;
|
|
37
|
+
readonly requestId: string | null;
|
|
38
|
+
readonly constructorSignature: string | null;
|
|
39
|
+
readonly constructorArgs: string | null;
|
|
40
|
+
readonly verifyCommand: string;
|
|
41
|
+
readonly explorer: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ShipReport {
|
|
44
|
+
readonly tool: 'redbelly-ship';
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly generatedAt: string;
|
|
47
|
+
/** UTC date the report is dated; the deploy script wants today's. */
|
|
48
|
+
readonly date: string;
|
|
49
|
+
readonly project: string;
|
|
50
|
+
readonly chain: 151 | 153;
|
|
51
|
+
readonly network: 'mainnet' | 'testnet';
|
|
52
|
+
readonly rpc: string;
|
|
53
|
+
readonly ok: boolean;
|
|
54
|
+
/** The file written, or null when a check failed. */
|
|
55
|
+
readonly written: string | null;
|
|
56
|
+
readonly checks: readonly ShipCheck[];
|
|
57
|
+
readonly preflight: PreflightReport;
|
|
58
|
+
readonly deployer: {
|
|
59
|
+
readonly address: string | null;
|
|
60
|
+
readonly how: string;
|
|
61
|
+
readonly isAllowed: boolean | null;
|
|
62
|
+
readonly wellKnownDevAccount: boolean;
|
|
63
|
+
};
|
|
64
|
+
readonly admin: (AdminReading & {
|
|
65
|
+
readonly isAllowed: boolean | null;
|
|
66
|
+
readonly owners: readonly string[] | null;
|
|
67
|
+
}) | null;
|
|
68
|
+
readonly verifier: {
|
|
69
|
+
readonly address: string | null;
|
|
70
|
+
readonly isContract: boolean | null;
|
|
71
|
+
readonly source: string;
|
|
72
|
+
};
|
|
73
|
+
readonly requestId: string | null;
|
|
74
|
+
readonly tests: SuiteSummary | null;
|
|
75
|
+
readonly gas: {
|
|
76
|
+
readonly quote: GasQuote;
|
|
77
|
+
readonly deployment: PricedRow | null;
|
|
78
|
+
readonly rows: readonly PricedRow[];
|
|
79
|
+
} | null;
|
|
80
|
+
readonly contracts: readonly ShipContract[];
|
|
81
|
+
readonly pins: {
|
|
82
|
+
readonly solc: string | null;
|
|
83
|
+
readonly evm: string | null;
|
|
84
|
+
readonly runs: number | null;
|
|
85
|
+
};
|
|
86
|
+
readonly forgeVersion: string | null;
|
|
87
|
+
readonly commit: string | null;
|
|
88
|
+
}
|
|
89
|
+
export declare function utcDate(d?: Date): string;
|
|
90
|
+
export declare function shipReportPath(dir: string, chain: 151 | 153, date?: string): string;
|
|
91
|
+
/** The header every generated report starts with, and what `status` and the tests read back. */
|
|
92
|
+
export declare const SHIP_HEADER: RegExp;
|
|
93
|
+
export declare function parseShipHeader(text: string): {
|
|
94
|
+
chain: number;
|
|
95
|
+
date: string;
|
|
96
|
+
ok: boolean;
|
|
97
|
+
generatedAt: string;
|
|
98
|
+
} | null;
|
|
99
|
+
export declare function routescanBase(chain: 151 | 153): {
|
|
100
|
+
verifier: string;
|
|
101
|
+
explorer: string;
|
|
102
|
+
};
|
|
103
|
+
/** The deployment records the deploy script wrote for this chain, with the verify command each needs. */
|
|
104
|
+
export declare function readShipContracts(dir: string, chain: 151 | 153, pins: ShipReport['pins']): ShipContract[];
|
|
105
|
+
export declare function runShip(opts: ShipOptions): Promise<ShipReport>;
|
|
106
|
+
export declare function renderShipMarkdown(r: ShipReport): string;
|
|
107
|
+
export declare function renderShipText(r: ShipReport): string;
|
|
108
|
+
export declare function shipReportJson(report: ShipReport): string;
|
package/dist/ship.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// redbelly ship: the mainnet gate that produces the document you wanted anyway (PLAN.md 18.3). Runs
|
|
2
|
+
// pre-flight with the keystore's address, checks the Slither sidecar, runs the tests and the
|
|
3
|
+
// five-state suites, prices the gas report on the chosen chain, reads the deployer and the admin
|
|
4
|
+
// (isAllowed, Safe version, threshold, owners), and writes deployments/ship-<chain>-<date>.md with
|
|
5
|
+
// every check and its result, the addresses, the constructor arguments and the verify command. It
|
|
6
|
+
// refuses to write when any check fails; the deploy script refuses to broadcast on 151 without a
|
|
7
|
+
// report dated today. Nothing here signs: the account name resolves to an address through cast,
|
|
8
|
+
// the RPC is read, forge runs tests on the local machine.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { chainById, decodeWords, ethCall, isAllowed, toCaller } from '@gatedpath/chains';
|
|
13
|
+
import { readProjectConfig } from './config.js';
|
|
14
|
+
import { readDotEnv } from './dotenv.js';
|
|
15
|
+
import { readAdmin } from './checks/admin.js';
|
|
16
|
+
import { parseGasReportJson, priceRows, quoteGas } from './gas.js';
|
|
17
|
+
import { run } from './proc.js';
|
|
18
|
+
import { DEFAULT_GAS, runPreflight } from './run.js';
|
|
19
|
+
import { findGatedSuites, summariseTests } from './suites.js';
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
export const SHIP_VERSION = require('../package.json').version;
|
|
22
|
+
const GET_OWNERS = '0xa0e67e2b'; // getOwners()
|
|
23
|
+
export function utcDate(d = new Date()) {
|
|
24
|
+
return d.toISOString().slice(0, 10);
|
|
25
|
+
}
|
|
26
|
+
export function shipReportPath(dir, chain, date = utcDate()) {
|
|
27
|
+
return join(dir, 'deployments', `ship-${chain}-${date}.md`);
|
|
28
|
+
}
|
|
29
|
+
/** The header every generated report starts with, and what `status` and the tests read back. */
|
|
30
|
+
export const SHIP_HEADER = /^<!-- redbelly-ship chain=(\d+) date=(\d{4}-\d{2}-\d{2}) ok=(true|false) generatedAt=(\S+) -->/;
|
|
31
|
+
export function parseShipHeader(text) {
|
|
32
|
+
const m = SHIP_HEADER.exec(text);
|
|
33
|
+
return m ? { chain: Number(m[1]), date: m[2], ok: m[3] === 'true', generatedAt: m[4] } : null;
|
|
34
|
+
}
|
|
35
|
+
async function safeOwners(rpc, safe) {
|
|
36
|
+
try {
|
|
37
|
+
const ret = await ethCall(rpc, safe, GET_OWNERS);
|
|
38
|
+
const words = decodeWords(ret);
|
|
39
|
+
const len = Number(words[1] ?? 0n);
|
|
40
|
+
return words.slice(2, 2 + len).map((w) => `0x${w.toString(16).padStart(64, '0').slice(24)}`);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function readJson(path) {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : null);
|
|
55
|
+
export function routescanBase(chain) {
|
|
56
|
+
return chain === 151
|
|
57
|
+
? { verifier: 'https://api.routescan.io/v2/network/mainnet/evm/151/etherscan', explorer: 'https://redbelly.routescan.io' }
|
|
58
|
+
: { verifier: 'https://api.routescan.io/v2/network/testnet/evm/153/etherscan', explorer: 'https://redbelly.testnet.routescan.io' };
|
|
59
|
+
}
|
|
60
|
+
/** The deployment records the deploy script wrote for this chain, with the verify command each needs. */
|
|
61
|
+
export function readShipContracts(dir, chain, pins) {
|
|
62
|
+
const dep = join(dir, 'deployments');
|
|
63
|
+
if (!existsSync(dep))
|
|
64
|
+
return [];
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const name of readdirSync(dep).sort()) {
|
|
67
|
+
if (!(name === `${chain}.json` || (name.startsWith(`${chain}-`) && name.endsWith('.json'))))
|
|
68
|
+
continue;
|
|
69
|
+
const record = readJson(join(dep, name));
|
|
70
|
+
const address = str(record?.['contract']);
|
|
71
|
+
if (!record || !address)
|
|
72
|
+
continue;
|
|
73
|
+
const contractName = str(record['contractName']) ?? basename(name, '.json').replace(new RegExp(`^${chain}-`), '');
|
|
74
|
+
const sig = str(record['constructorSignature']);
|
|
75
|
+
const args = str(record['constructorArgs']);
|
|
76
|
+
const rs = routescanBase(chain);
|
|
77
|
+
const verifyCommand = `forge verify-contract ${address} src/${contractName}.sol:${contractName} --verifier etherscan --verifier-url ${rs.verifier} --etherscan-api-key verifyContract --chain ${chain} --compiler-version ${pins.solc ?? '0.8.30'} --evm-version ${pins.evm ?? 'prague'}${pins.runs !== null ? ` --num-of-optimizations ${pins.runs}` : ''} --constructor-args ${args ?? '<cast abi-encode output>'} --watch`;
|
|
78
|
+
out.push({
|
|
79
|
+
name: contractName,
|
|
80
|
+
address,
|
|
81
|
+
record: join(dep, name),
|
|
82
|
+
deployer: str(record['deployer']),
|
|
83
|
+
admin: str(record['admin']),
|
|
84
|
+
verifier: str(record['verifier']),
|
|
85
|
+
requestId: str(record['requestId']),
|
|
86
|
+
constructorSignature: sig,
|
|
87
|
+
constructorArgs: args,
|
|
88
|
+
verifyCommand,
|
|
89
|
+
explorer: `${rs.explorer}/address/${address}/contract/${chain}/code`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
export async function runShip(opts) {
|
|
95
|
+
const env = opts.env ?? process.env;
|
|
96
|
+
const project = resolve(opts.project ?? process.cwd());
|
|
97
|
+
const dir = !existsSync(join(project, 'foundry.toml')) && existsSync(join(project, 'contracts', 'foundry.toml')) ? join(project, 'contracts') : project;
|
|
98
|
+
if (!existsSync(join(dir, 'foundry.toml')))
|
|
99
|
+
throw new Error(`no foundry.toml in ${project} or ${join(project, 'contracts')}; ship drives forge and reads the Foundry project`);
|
|
100
|
+
const chain = opts.chain;
|
|
101
|
+
const network = chain === 151 ? 'mainnet' : 'testnet';
|
|
102
|
+
const dotenv = readDotEnv(dir, env);
|
|
103
|
+
const rootEnv = dir === project ? dotenv : { ...readDotEnv(project, env), ...dotenv };
|
|
104
|
+
const rpc = opts.rpc ?? chainById(chain).rpcUrls.default.http[0];
|
|
105
|
+
const config = readProjectConfig(dir, undefined, env);
|
|
106
|
+
const pins = { solc: config.solc, evm: config.evmVersion, runs: config.optimizerRuns };
|
|
107
|
+
const checks = [];
|
|
108
|
+
// forge, and the project's commit, for the record
|
|
109
|
+
const fv = await run('forge', ['--version'], { cwd: dir, timeoutMs: 20_000 });
|
|
110
|
+
const forgeVersion = fv.spawnError ? null : (/(\d+\.\d+\.\d+)/.exec(fv.stdout + fv.stderr)?.[1] ?? null);
|
|
111
|
+
const git = await run('git', ['rev-parse', 'HEAD'], { cwd: dir, timeoutMs: 20_000 });
|
|
112
|
+
const commit = git.code === 0 ? git.stdout.trim() : null;
|
|
113
|
+
// 1. gas report first: it gives the deployment figure the balance check prices
|
|
114
|
+
let gas = null;
|
|
115
|
+
let quote = null;
|
|
116
|
+
try {
|
|
117
|
+
quote = await quoteGas(rpc, chain);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
checks.push({ id: 'gas', status: 'fail', reason: `Could not read the base fee and feed price from ${rpc}: ${e.message}.` });
|
|
121
|
+
}
|
|
122
|
+
const gr = forgeVersion ? await run('forge', ['test', '--gas-report', '--json'], { cwd: dir, env, timeoutMs: 900_000 }) : null;
|
|
123
|
+
let deploymentGas = null;
|
|
124
|
+
if (gr && quote) {
|
|
125
|
+
const start = gr.stdout.indexOf('[');
|
|
126
|
+
let rows = [];
|
|
127
|
+
if (gr.code === 0 && start >= 0) {
|
|
128
|
+
try {
|
|
129
|
+
rows = parseGasReportJson(JSON.parse(gr.stdout.slice(start)));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
rows = [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (rows.length === 0) {
|
|
136
|
+
checks.push({ id: 'gas', status: gr.code === 0 ? 'warn' : 'fail', reason: gr.code === 0 ? 'forge test --gas-report --json listed no contract; set gas_reports in foundry.toml so the report can price the deployment.' : `forge test --gas-report --json exited ${gr.code}: ${(gr.stderr || gr.stdout).trim().slice(-400)}` });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const priced = priceRows(rows, quote).rows;
|
|
140
|
+
const deployment = priced.find((r) => r.item === 'deployment') ?? null;
|
|
141
|
+
deploymentGas = deployment?.gas ?? null;
|
|
142
|
+
gas = { quote, deployment, rows: priced };
|
|
143
|
+
checks.push({ id: 'gas', status: 'pass', reason: `${priced.length} rows priced at block ${quote.block.toLocaleString('en-US')}: base fee ${(Number(quote.baseFeeWei) / 1e9).toLocaleString('en-US', { maximumFractionDigits: 2 })} gwei, RBNT US$${quote.usdPerRbnt}${deployment ? `; ${deployment.contract} deploys for ${deployment.gas.toLocaleString('en-US')} gas = ${Number(deployment.rbnt).toFixed(4)} RBNT = ${deployment.cents.toFixed(2)} US cents` : ''}.`, data: { block: quote.block, baseFeeWei: quote.baseFeeWei.toString(), usdPerRbnt: quote.usdPerRbnt, deploymentGas: deploymentGas?.toString() ?? null } });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (!forgeVersion) {
|
|
147
|
+
checks.push({ id: 'gas', status: 'fail', reason: 'forge is not installed or not on PATH; run redbelly-doctor.' });
|
|
148
|
+
}
|
|
149
|
+
// 2. pre-flight, seven checks, with the keystore's address and the measured deployment gas
|
|
150
|
+
const gasForBalance = opts.gas !== undefined ? BigInt(opts.gas) : (deploymentGas ?? DEFAULT_GAS);
|
|
151
|
+
const admin = opts.admin ?? rootEnv['ADMIN_SAFE'];
|
|
152
|
+
const preflight = await runPreflight({ project: dir, chain, rpc, account: opts.account, ...(opts.passwordFile ? { passwordFile: opts.passwordFile } : {}), ...(admin ? { admin } : {}), gas: gasForBalance, env });
|
|
153
|
+
for (const c of preflight.checks)
|
|
154
|
+
checks.push({ id: c.id, status: c.status, reason: c.reason, ...(c.data ? { data: c.data } : {}) });
|
|
155
|
+
if (chain === 151 && !admin)
|
|
156
|
+
checks.push({ id: 'admin-required', status: 'fail', reason: 'Chain 151 needs --admin <safe> (or ADMIN_SAFE in .env): the Safe 1.4.1 that receives every role.' });
|
|
157
|
+
// 3. the verifier the deploy script will bind
|
|
158
|
+
const verifierAddr = opts.verifier ?? rootEnv['VERIFIER'] ?? null;
|
|
159
|
+
const verifierSource = opts.verifier ? '--verifier' : rootEnv['VERIFIER'] ? 'VERIFIER in .env' : 'none';
|
|
160
|
+
let verifierIsContract = null;
|
|
161
|
+
if (verifierAddr) {
|
|
162
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(verifierAddr))
|
|
163
|
+
checks.push({ id: 'verifier', status: 'fail', reason: `${verifierSource} is not a 20-byte hex address.` });
|
|
164
|
+
else {
|
|
165
|
+
try {
|
|
166
|
+
const code = (await toCaller(rpc)('eth_getCode', [verifierAddr, 'latest']));
|
|
167
|
+
verifierIsContract = code !== '0x' && code.length > 2;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
verifierIsContract = null;
|
|
171
|
+
}
|
|
172
|
+
if (verifierIsContract)
|
|
173
|
+
checks.push({ id: 'verifier', status: 'pass', reason: `${verifierAddr} (${verifierSource}) has code on chain ${chain}.`, data: { address: verifierAddr } });
|
|
174
|
+
else
|
|
175
|
+
checks.push({ id: 'verifier', status: 'fail', reason: `${verifierAddr} (${verifierSource}) has no code on chain ${chain}; the deploy script requires a contract.`, data: { address: verifierAddr } });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (chain === 151)
|
|
179
|
+
checks.push({ id: 'verifier', status: 'fail', reason: 'No VERIFIER: mainnet never deploys a mock verifier. Set VERIFIER in .env or pass --verifier.' });
|
|
180
|
+
else
|
|
181
|
+
checks.push({ id: 'verifier', status: 'warn', reason: 'No VERIFIER: the deploy script will deploy a ReceptorMock on 153 and every wallet starts ineligible. Fine for testnet; mainnet refuses.' });
|
|
182
|
+
const requestId = rootEnv['REQUEST_ID'] ?? null;
|
|
183
|
+
// 4. tests and the five-state suites
|
|
184
|
+
let tests = null;
|
|
185
|
+
if (forgeVersion) {
|
|
186
|
+
const suites = findGatedSuites(dir);
|
|
187
|
+
const t = await run('forge', ['test', '--json'], { cwd: dir, env, timeoutMs: 900_000 });
|
|
188
|
+
let parsed = null;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(t.stdout);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
parsed = null;
|
|
194
|
+
}
|
|
195
|
+
if (!parsed) {
|
|
196
|
+
checks.push({ id: 'tests', status: 'fail', reason: `forge test did not print JSON (exit ${t.code}): ${(t.stderr || t.stdout).trim().slice(-400)}` });
|
|
197
|
+
checks.push({ id: 'five-state-tests', status: 'fail', reason: 'Not run: forge test failed above.' });
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
tests = summariseTests(parsed, suites);
|
|
201
|
+
checks.push(tests.failed === 0 && tests.passed > 0 ? { id: 'tests', status: 'pass', reason: `${tests.passed} passed, ${tests.failed} failed${tests.skipped ? `, ${tests.skipped} skipped` : ''}.`, data: { passed: tests.passed, failed: tests.failed } } : { id: 'tests', status: 'fail', reason: tests.passed === 0 ? 'No test ran.' : `${tests.failed} failed: ${tests.failures.slice(0, 3).map((f) => `${f.suite}.${f.test}`).join(', ')}${tests.failures.length > 3 ? ', …' : ''}.`, data: { passed: tests.passed, failed: tests.failed, failures: tests.failures } });
|
|
202
|
+
checks.push(tests.fiveState
|
|
203
|
+
? { id: 'five-state-tests', status: 'pass', reason: `${suites.length} suite${suites.length === 1 ? '' : 's'} inherit GatedTest and pass: ${tests.suites.map((s) => `${s.name} (${s.passed})`).join(', ')}.`, data: { suites: tests.suites } }
|
|
204
|
+
: { id: 'five-state-tests', status: 'fail', reason: suites.length === 0 ? 'No test contract inherits GatedTest; every gated function needs a suite proven against all five credential states.' : `A GatedTest suite failed or ran nothing: ${tests.suites.filter((s) => s.failed > 0 || s.passed === 0).map((s) => `${s.name} (${s.passed} passed, ${s.failed} failed)`).join(', ')}.`, data: { suites: tests.suites } });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
checks.push({ id: 'tests', status: 'fail', reason: 'forge is not installed or not on PATH.' });
|
|
209
|
+
checks.push({ id: 'five-state-tests', status: 'fail', reason: 'Not run: forge missing.' });
|
|
210
|
+
}
|
|
211
|
+
// 5. the readings the report carries: deployer and admin on this chain
|
|
212
|
+
const deployerCheck = preflight.checks.find((c) => c.id === 'deployer-verified');
|
|
213
|
+
const deployer = {
|
|
214
|
+
address: preflight.deployer,
|
|
215
|
+
how: deployerCheck?.data?.['how'] ?? (opts.account ? `cast wallet address --account ${opts.account}` : 'none'),
|
|
216
|
+
isAllowed: typeof deployerCheck?.data?.['allowed'] === 'boolean' ? deployerCheck.data['allowed'] : null,
|
|
217
|
+
wellKnownDevAccount: deployerCheck?.data?.['wellKnownDevAccount'] === true,
|
|
218
|
+
};
|
|
219
|
+
let adminReading = null;
|
|
220
|
+
if (admin && /^0x[0-9a-fA-F]{40}$/.test(admin)) {
|
|
221
|
+
try {
|
|
222
|
+
const reading = await readAdmin(admin, rpc, network);
|
|
223
|
+
let allowed = null;
|
|
224
|
+
try {
|
|
225
|
+
allowed = await isAllowed(admin, { rpc });
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
allowed = null;
|
|
229
|
+
}
|
|
230
|
+
adminReading = { ...reading, isAllowed: allowed, owners: reading.isContract ? await safeOwners(rpc, admin) : null };
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
adminReading = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const contracts = readShipContracts(dir, chain, pins);
|
|
237
|
+
const ok = checks.every((c) => c.status !== 'fail');
|
|
238
|
+
const date = utcDate();
|
|
239
|
+
const out = opts.out ? resolve(dir, opts.out) : shipReportPath(dir, chain, date);
|
|
240
|
+
const report = {
|
|
241
|
+
tool: 'redbelly-ship',
|
|
242
|
+
version: SHIP_VERSION,
|
|
243
|
+
generatedAt: new Date().toISOString(),
|
|
244
|
+
date,
|
|
245
|
+
project: dir,
|
|
246
|
+
chain,
|
|
247
|
+
network,
|
|
248
|
+
rpc,
|
|
249
|
+
ok,
|
|
250
|
+
written: null,
|
|
251
|
+
checks,
|
|
252
|
+
preflight,
|
|
253
|
+
deployer,
|
|
254
|
+
admin: adminReading,
|
|
255
|
+
verifier: { address: verifierAddr, isContract: verifierIsContract, source: verifierSource },
|
|
256
|
+
requestId,
|
|
257
|
+
tests,
|
|
258
|
+
gas,
|
|
259
|
+
contracts,
|
|
260
|
+
pins,
|
|
261
|
+
forgeVersion,
|
|
262
|
+
commit,
|
|
263
|
+
};
|
|
264
|
+
if (!ok)
|
|
265
|
+
return report;
|
|
266
|
+
mkdirSync(join(dir, 'deployments'), { recursive: true });
|
|
267
|
+
writeFileSync(out, renderShipMarkdown(report));
|
|
268
|
+
return { ...report, written: out };
|
|
269
|
+
}
|
|
270
|
+
// ---- the document ----
|
|
271
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
272
|
+
const short = (s) => (s && s.includes('.') ? s.replace(/(\.\d{4})\d+$/, '$1') : s ?? '');
|
|
273
|
+
export function renderShipMarkdown(r) {
|
|
274
|
+
const L = [];
|
|
275
|
+
const fails = r.checks.filter((c) => c.status === 'fail');
|
|
276
|
+
const warns = r.checks.filter((c) => c.status === 'warn');
|
|
277
|
+
L.push(`<!-- redbelly-ship chain=${r.chain} date=${r.date} ok=${r.ok} generatedAt=${r.generatedAt} -->`);
|
|
278
|
+
L.push(`# Ship report: chain ${r.chain} (${r.network}), ${r.date}`);
|
|
279
|
+
L.push('');
|
|
280
|
+
L.push(`Written by \`redbelly ship\` ${r.version} at ${r.generatedAt} for \`${r.project}\`${r.commit ? ` at commit \`${r.commit.slice(0, 12)}\`` : ''}, read-only against ${r.rpc}. ${r.ok ? `Every check passed${warns.length ? ` with ${warns.length} warning${warns.length === 1 ? '' : 's'}` : ''}; the deploy script accepts a broadcast on chain ${r.chain} dated today with this file present.` : `${fails.length} check${fails.length === 1 ? '' : 's'} failed, so this document was not written to deployments/.`} Nothing here signs. The deployer's address came from \`${r.deployer.how}\`; the key stayed in the keystore.`);
|
|
281
|
+
L.push('');
|
|
282
|
+
L.push('## Checks');
|
|
283
|
+
L.push('');
|
|
284
|
+
L.push('| Check | Result | Reason |');
|
|
285
|
+
L.push('|---|---|---|');
|
|
286
|
+
for (const c of r.checks)
|
|
287
|
+
L.push(`| \`${c.id}\` | ${c.status} | ${c.reason.replace(/\|/g, '\\|')} |`);
|
|
288
|
+
L.push('');
|
|
289
|
+
L.push('## Deployer and admin');
|
|
290
|
+
L.push('');
|
|
291
|
+
L.push(`Deployer ${r.deployer.address ?? 'unknown'}: \`permission.isAllowed\` ${r.deployer.isAllowed === null ? 'not read' : r.deployer.isAllowed}${r.deployer.wellKnownDevAccount ? ' (one of the ten Anvil default accounts, whose keys are public; never for a real network)' : ''}.`);
|
|
292
|
+
if (r.admin) {
|
|
293
|
+
const a = r.admin;
|
|
294
|
+
L.push(`Admin ${a.address}: ${a.isContract ? `a contract on a ${a.singletonKind ? `canonical ${a.singletonKind === 'safeL2Singleton' ? 'SafeL2' : 'Safe'} 1.4.1 singleton (code ${a.singletonCodeMatches ? 'matches the 1.4.1 release' : 'does not match the 1.4.1 release'})` : 'singleton this tool does not recognise'}, threshold ${a.threshold ?? 'unreadable'}${a.owners ? `, ${a.owners.length} owner${a.owners.length === 1 ? '' : 's'}: ${a.owners.join(', ')}` : ''}` : 'an externally owned account, which chain 151 refuses'}; \`permission.isAllowed\` ${a.isAllowed === null ? 'not read' : a.isAllowed}.`);
|
|
295
|
+
}
|
|
296
|
+
else
|
|
297
|
+
L.push(`Admin: none given${r.chain === 153 ? '; on 153 the deployer holds every role' : ''}.`);
|
|
298
|
+
L.push(`Verifier: ${r.verifier.address ? `${r.verifier.address} (${r.verifier.source}), ${r.verifier.isContract === null ? 'code not read' : r.verifier.isContract ? 'has code' : 'no code'}` : r.chain === 151 ? 'none; mainnet refuses' : 'none; the deploy script deploys a ReceptorMock on 153'}. Request id ${r.requestId ?? 'the deploy script\'s default (18, the over-18 recipe)'}.`);
|
|
299
|
+
L.push('');
|
|
300
|
+
L.push('## Contracts');
|
|
301
|
+
L.push('');
|
|
302
|
+
if (r.contracts.length === 0) {
|
|
303
|
+
L.push(`No deployment record for chain ${r.chain} under \`deployments/\` yet. Deploy with \`forge script script/Deploy.s.sol --rpc-url ${r.network === 'mainnet' ? 'redbelly_mainnet' : 'redbelly_testnet'} --account <keystore-name> --broadcast\`, then run \`redbelly ship\` again today: this section fills in with the address, the constructor arguments and the exact verify command.`);
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
for (const c of r.contracts) {
|
|
307
|
+
L.push(`### ${c.name} at ${c.address}`);
|
|
308
|
+
L.push('');
|
|
309
|
+
L.push(`Record \`${relative(r.project, c.record)}\`${c.deployer ? `, deployed by ${c.deployer}` : ''}${c.admin ? `, admin ${c.admin}` : ''}${c.verifier ? `, verifier ${c.verifier}` : ''}${c.requestId ? `, request id ${c.requestId}` : ''}. Explorer: ${c.explorer}`);
|
|
310
|
+
L.push('');
|
|
311
|
+
if (c.constructorSignature)
|
|
312
|
+
L.push(`Constructor \`${c.constructorSignature}\`, arguments as encoded by the deploy script:`);
|
|
313
|
+
else
|
|
314
|
+
L.push('The record carries no constructor arguments (written by an older deploy script); encode them with `cast abi-encode` for the command below.');
|
|
315
|
+
L.push('');
|
|
316
|
+
if (c.constructorArgs)
|
|
317
|
+
L.push('```', c.constructorArgs, '```', '');
|
|
318
|
+
L.push('Verify on Routescan:');
|
|
319
|
+
L.push('');
|
|
320
|
+
L.push('```sh', c.verifyCommand, '```');
|
|
321
|
+
L.push('');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
L.push('## Gas');
|
|
325
|
+
L.push('');
|
|
326
|
+
if (r.gas) {
|
|
327
|
+
const q = r.gas.quote;
|
|
328
|
+
L.push(`Priced at block ${fmt(q.block)}: base fee ${(Number(q.baseFeeWei) / 1e9).toLocaleString('en-US', { maximumFractionDigits: 2 })} gwei, RBNT US$${q.usdPerRbnt} on the feed at ${q.priceFeed} (${q.priceTimestamp}). Gas is priced in US dollars on this chain and converted to RBNT at execution, so the cents column is the stable one.`);
|
|
329
|
+
L.push('');
|
|
330
|
+
if (r.gas.deployment)
|
|
331
|
+
L.push(`Deployment of ${r.gas.deployment.contract}: ${fmt(r.gas.deployment.gas)} gas = ${short(r.gas.deployment.rbnt)} RBNT = ${r.gas.deployment.cents.toFixed(2)} US cents. Pre-flight's balance check used ${fmt(BigInt(r.preflight.checks.find((c) => c.id === 'balance')?.data?.['gas'] ?? '0'))} gas with 25% on top.`);
|
|
332
|
+
L.push('');
|
|
333
|
+
L.push('| Function | Gas | RBNT | US cents |');
|
|
334
|
+
L.push('|---|---:|---:|---:|');
|
|
335
|
+
for (const row of r.gas.rows)
|
|
336
|
+
L.push(`| \`${row.name}\` | ${fmt(row.gas)} | ${short(row.rbnt)} | ${row.cents.toFixed(3)} |`);
|
|
337
|
+
}
|
|
338
|
+
else
|
|
339
|
+
L.push('Not priced; see the `gas` check above.');
|
|
340
|
+
L.push('');
|
|
341
|
+
L.push('## Tests');
|
|
342
|
+
L.push('');
|
|
343
|
+
if (r.tests) {
|
|
344
|
+
L.push(`\`forge test\`: ${r.tests.passed} passed, ${r.tests.failed} failed${r.tests.skipped ? `, ${r.tests.skipped} skipped` : ''}${r.forgeVersion ? ` on forge ${r.forgeVersion}` : ''}. Five-state suites (inherit \`GatedTest\`): ${r.tests.suites.length ? r.tests.suites.map((s) => `${s.name} ${s.passed} passed, ${s.failed} failed`).join('; ') : 'none'}.`);
|
|
345
|
+
}
|
|
346
|
+
else
|
|
347
|
+
L.push('Not run; see the `tests` check above.');
|
|
348
|
+
L.push('');
|
|
349
|
+
L.push('## Static analysis');
|
|
350
|
+
L.push('');
|
|
351
|
+
const sl = r.preflight.checks.find((c) => c.id === 'slither-report');
|
|
352
|
+
L.push(sl ? `Slither: ${sl.status}. ${sl.reason}` : 'Slither: not checked.');
|
|
353
|
+
L.push('');
|
|
354
|
+
L.push('## Compiler');
|
|
355
|
+
L.push('');
|
|
356
|
+
L.push(`solc ${r.pins.solc ?? 'unknown'}, EVM ${r.pins.evm ?? 'unknown'}, optimizer runs ${r.pins.runs ?? 'unknown'}${r.forgeVersion ? `, forge ${r.forgeVersion}` : ''}. Both networks run Prague and Vine states 0.8.30.`);
|
|
357
|
+
L.push('');
|
|
358
|
+
L.push('## What this report does not prove');
|
|
359
|
+
L.push('');
|
|
360
|
+
L.push('Monitoring live (the ops kit\'s alert poller on this contract), a `security.txt` at the web app\'s root, an audit or a bug bounty, and that the Safe\'s owners are the people you think. The builder site\'s review page lists them in order; tick them by hand under this line before mainnet.');
|
|
361
|
+
L.push('');
|
|
362
|
+
return L.join('\n');
|
|
363
|
+
}
|
|
364
|
+
export function renderShipText(r) {
|
|
365
|
+
const lines = [];
|
|
366
|
+
lines.push(`redbelly ship ${r.version} project ${r.project} chain ${r.chain} (${r.network}) via ${r.rpc}`);
|
|
367
|
+
lines.push(`deployer ${r.deployer.address ?? 'unknown'} (${r.deployer.how})${r.admin ? ` admin ${r.admin.address}` : ''}${r.verifier.address ? ` verifier ${r.verifier.address}` : ''}`);
|
|
368
|
+
lines.push('');
|
|
369
|
+
for (const c of r.checks)
|
|
370
|
+
lines.push(`${c.status.padEnd(5)} ${c.id.padEnd(18)} ${c.reason}`);
|
|
371
|
+
lines.push('');
|
|
372
|
+
const fails = r.checks.filter((c) => c.status === 'fail');
|
|
373
|
+
if (r.ok)
|
|
374
|
+
lines.push(`ok: every check passed. Report written: ${r.written}. The deploy script accepts a broadcast on ${r.chain} today with it present.`);
|
|
375
|
+
else
|
|
376
|
+
lines.push(`not ok: ${fails.length} check${fails.length === 1 ? '' : 's'} failed (${fails.map((c) => c.id).join(', ')}). No report written; the deploy script refuses 151 without one. Fix each line, then run redbelly ship again.`);
|
|
377
|
+
if (r.gas?.deployment)
|
|
378
|
+
lines.push(`gas: ${r.gas.deployment.contract} deploys for ${fmt(r.gas.deployment.gas)} gas = ${short(r.gas.deployment.rbnt)} RBNT = ${r.gas.deployment.cents.toFixed(2)} US cents at block ${fmt(r.gas.quote.block)}.`);
|
|
379
|
+
return lines.join('\n') + '\n';
|
|
380
|
+
}
|
|
381
|
+
export function shipReportJson(report) {
|
|
382
|
+
return JSON.stringify(report, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2) + '\n';
|
|
383
|
+
}
|
package/dist/suites.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare function findGatedSuites(project: string, testDir?: string): string[];
|
|
2
|
+
export type ForgeTestJson = Record<string, {
|
|
3
|
+
test_results: Record<string, {
|
|
4
|
+
status: string;
|
|
5
|
+
reason?: string | null;
|
|
6
|
+
}>;
|
|
7
|
+
}>;
|
|
8
|
+
export interface SuiteSummary {
|
|
9
|
+
readonly passed: number;
|
|
10
|
+
readonly failed: number;
|
|
11
|
+
readonly skipped: number;
|
|
12
|
+
readonly suites: ReadonlyArray<{
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly passed: number;
|
|
15
|
+
readonly failed: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** True when at least one suite inherits GatedTest, every one of them ran, and none failed a test. */
|
|
18
|
+
readonly fiveState: boolean;
|
|
19
|
+
readonly failures: ReadonlyArray<{
|
|
20
|
+
readonly suite: string;
|
|
21
|
+
readonly test: string;
|
|
22
|
+
readonly reason: string | null;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
/** Counts a `forge test --json` result against the suites that inherit GatedTest. */
|
|
26
|
+
export declare function summariseTests(parsed: ForgeTestJson, gatedSuites: readonly string[]): SuiteSummary;
|