From df0146fe12d76778395b497857db931ca3222d87 Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:52:56 +0200 Subject: [PATCH 1/3] chore: normalize changelog formatting --- CHANGELOG.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b9290..eba3f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ # Changelog - ## v0.3.2 [compare changes](https://github.com/agntn/chains/compare/v0.3.1...v0.3.2) @@ -46,11 +45,11 @@ ### 💅 Refactors - Registry without import side effects ([#10](https://github.com/agntn/chains/pull/10)) -- ⚠️ Canonical keys name the chain, not the ticker ([#12](https://github.com/agntn/chains/pull/12)) +- ⚠️ Canonical keys name the chain, not the ticker ([#12](https://github.com/agntn/chains/pull/12)) #### ⚠️ Breaking Changes -- ⚠️ Canonical keys name the chain, not the ticker ([#12](https://github.com/agntn/chains/pull/12)) +- ⚠️ Canonical keys name the chain, not the ticker ([#12](https://github.com/agntn/chains/pull/12)) ### ❤️ Contributors @@ -111,12 +110,11 @@ ## v0.2.0 - ### 🚀 Enhancements - Chains — blockchain data dictionary (18 chains, aliases, inter-lib bridges, address validation) ([2522db9](https://github.com/agntn/chains/commit/2522db9)) - Add Octra chain metadata ([550491a](https://github.com/agntn/chains/commit/550491a)) -- ⚠️ Class registry, @agntn scope, CLI and agent extensions ([4137cb2](https://github.com/agntn/chains/commit/4137cb2)) +- ⚠️ Class registry, @agntn scope, CLI and agent extensions ([4137cb2](https://github.com/agntn/chains/commit/4137cb2)) - Resolve chain display names from the registry ([9a9d59e](https://github.com/agntn/chains/commit/9a9d59e)) - MCP server over stdio with shared tool executors ([ec68c07](https://github.com/agntn/chains/commit/ec68c07)) @@ -132,9 +130,8 @@ #### ⚠️ Breaking Changes -- ⚠️ Class registry, @agntn scope, CLI and agent extensions ([4137cb2](https://github.com/agntn/chains/commit/4137cb2)) +- ⚠️ Class registry, @agntn scope, CLI and agent extensions ([4137cb2](https://github.com/agntn/chains/commit/4137cb2)) ### ❤️ Contributors - Oritwoen ([@oritwoen](https://github.com/oritwoen)) - From ad5b449b19209cdd594e4871e96eb1ce0a9387e7 Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:53:25 +0200 Subject: [PATCH 2/3] refactor: satisfy shared lint policy --- packages/omp/extensions/chains.ts | 2 ++ packages/pi/extensions/chains.ts | 2 ++ src/chains/bitcoin.ts | 3 ++ src/chains/cardano.ts | 35 ++++++++++++++---- src/chains/ecash.ts | 3 ++ src/chains/litecoin.ts | 3 ++ src/chains/octra.ts | 3 ++ src/chains/pepecoin.ts | 3 ++ src/chains/stellar.ts | 20 ++++++++--- src/chains/ton.ts | 7 ++-- src/chains/tron.ts | 3 ++ src/chains/xrpl.ts | 17 +++++++-- src/commands/shared.ts | 3 ++ src/core/base58.ts | 5 +++ src/core/chain.ts | 2 ++ src/core/errors.ts | 2 +- src/core/identify.ts | 3 ++ src/core/resolve.ts | 3 ++ src/core/text.ts | 14 ++++++-- src/mcp.ts | 29 ++++++++++++--- src/tool-operations.ts | 20 +++++++---- test/unit/cli.test.ts | 32 ++++++++++------- test/unit/mcp.test.ts | 59 +++++++++++++++---------------- 23 files changed, 203 insertions(+), 70 deletions(-) diff --git a/packages/omp/extensions/chains.ts b/packages/omp/extensions/chains.ts index 9ecdb00..424b43c 100644 --- a/packages/omp/extensions/chains.ts +++ b/packages/omp/extensions/chains.ts @@ -21,6 +21,8 @@ let toolOperationsPromise: Promise | undefined; * under NodeNext resolution, which a bare TypeScript-stripping runtime cannot * resolve back to `.ts` files. Run `pnpm build` before loading the extension * from a working tree. + * + * @returns {Promise} The cached shared executor module. */ function loadToolOperations(): Promise { toolOperationsPromise ??= ( diff --git a/packages/pi/extensions/chains.ts b/packages/pi/extensions/chains.ts index 1c0ac14..166b253 100644 --- a/packages/pi/extensions/chains.ts +++ b/packages/pi/extensions/chains.ts @@ -18,6 +18,8 @@ let toolOperationsPromise: Promise | undefined; * specifiers under NodeNext resolution, which a bare TypeScript-stripping runtime * cannot resolve back to `.ts` files. Run `pnpm build` before loading the extension * from a working tree. + * + * @returns {Promise} The cached shared executor module. */ function loadToolOperations(): Promise { toolOperationsPromise ??= import( diff --git a/src/chains/bitcoin.ts b/src/chains/bitcoin.ts index 8e848c3..d12e968 100644 --- a/src/chains/bitcoin.ts +++ b/src/chains/bitcoin.ts @@ -23,6 +23,9 @@ export class Bitcoin extends Chain { * all. Decoding is the check the format needs, because a character-length * window lets any 32-byte base58 key through and Solana's System Program is * exactly that. The checksum stays unchecked: this is a format check. + * + * @param {string} address - Candidate Bitcoin address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 35); diff --git a/src/chains/cardano.ts b/src/chains/cardano.ts index 57ed7b0..29a2f9b 100644 --- a/src/chains/cardano.ts +++ b/src/chains/cardano.ts @@ -13,21 +13,39 @@ const SHELLEY_ADDRESS = const STAKE_ADDRESS = /^(stake1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{53}|STAKE1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{53})$/; +const BYRON_ENVELOPE_PREFIX = [0x82, 0xd8, 0x18, 0x58] as const; +const BYRON_PAYLOAD_PREFIX = [0x83, 0x58, 0x1c] as const; + +function hasBytesAt(decoded: ArrayLike, expected: readonly number[], offset = 0): boolean { + return expected.every((byte, index) => decoded[offset + index] === byte); +} + +function cborUnsignedLength(head: number): number { + if (head <= 0x17) return 1; + if (head === 0x18) return 2; + if (head === 0x19) return 3; + if (head === 0x1a) return 5; + return 0; +} + /** * The Byron envelope: array(2), tag(24), bytes opening as the three-item * array with its 28-byte root, then a CRC head matching the bytes it * claims. Attributes, type and the CRC value stay unparsed on purpose. + * + * @param {ArrayLike} decoded - Candidate decoded Byron address bytes. + * @returns {boolean} Whether the bytes have the expected Byron CBOR envelope. */ -function isByronEnvelope(decoded: Uint8Array): boolean { - if (decoded[0] !== 0x82 || decoded[1] !== 0xd8 || decoded[2] !== 0x18 || decoded[3] !== 0x58) { - return false; - } +function isByronEnvelope(decoded: ArrayLike): boolean { + if (!hasBytesAt(decoded, BYRON_ENVELOPE_PREFIX)) return false; + const payloadLength = decoded[4] ?? 0; - if (payloadLength < 33) return false; - if (decoded[5] !== 0x83 || decoded[6] !== 0x58 || decoded[7] !== 0x1c) return false; + if (payloadLength < 33 || !hasBytesAt(decoded, BYRON_PAYLOAD_PREFIX, 5)) return false; + const head = decoded[5 + payloadLength]; if (head === undefined) return false; - const crcBytes = head <= 0x17 ? 1 : head === 0x18 ? 2 : head === 0x19 ? 3 : head === 0x1a ? 5 : 0; + + const crcBytes = cborUnsignedLength(head); return crcBytes > 0 && decoded.length === 5 + payloadLength + crcBytes; } @@ -44,6 +62,9 @@ export class Cardano extends Chain { * A format check: the bech32 checksum and the Byron CRC stay unverified, * and a Byron testnet address passes because its network hides in a CBOR * attribute this check does not open. + * + * @param {string} address - Candidate Cardano address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 128); diff --git a/src/chains/ecash.ts b/src/chains/ecash.ts index 99b6719..419fa02 100644 --- a/src/chains/ecash.ts +++ b/src/chains/ecash.ts @@ -20,6 +20,9 @@ export class Ecash extends Chain { * upstream's ecashaddrjs. The checksum stays unchecked, so a prefixless * Bitcoin Cash address passes. Legacy base58 stays out: it is byte-identical * to a Bitcoin address. + * + * @param {string} address - Candidate eCash address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { if (!CASHADDR_ADDRESS.test(address)) { diff --git a/src/chains/litecoin.ts b/src/chains/litecoin.ts index 13e3525..292ff9a 100644 --- a/src/chains/litecoin.ts +++ b/src/chains/litecoin.ts @@ -22,6 +22,9 @@ export class Litecoin extends Chain { * byte-identical to a Bitcoin `3...` address, so accepting it would make every * such address identify as both chains. The checksum stays unchecked: this is * a format check. + * + * @param {string} address - Candidate Litecoin address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 35); diff --git a/src/chains/octra.ts b/src/chains/octra.ts index 15700cd..2acab95 100644 --- a/src/chains/octra.ts +++ b/src/chains/octra.ts @@ -15,6 +15,9 @@ export class Octra extends Chain { /** * The width is the whole format. A contract address is cut out of base58 * rather than encoded from a payload, so decoding it drops real contracts. + * + * @param {string} address - Candidate Octra address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { if (!ADDRESS.test(address)) { diff --git a/src/chains/pepecoin.ts b/src/chains/pepecoin.ts index 3a27724..ecc8fad 100644 --- a/src/chains/pepecoin.ts +++ b/src/chains/pepecoin.ts @@ -15,6 +15,9 @@ export class Pepecoin extends Chain { * Base58Check under 0x38 (`P...`) and 0x16 (`9...` or `A...`), 25 bytes, checksum * unchecked. No bech32: the fork carries Dogecoin's timed-out segwit. 0x16 is * Dogecoin's script version too, kept because Pepecoin has no other one. + * + * @param {string} address - Candidate Pepecoin address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 34); diff --git a/src/chains/stellar.ts b/src/chains/stellar.ts index b387279..35c250e 100644 --- a/src/chains/stellar.ts +++ b/src/chains/stellar.ts @@ -10,7 +10,13 @@ const STRKEY_TYPES: Readonly< C: { bytes: 35, version: 2 << 3 }, }; -/** Decodes canonical RFC 4648 base32 without padding or ignored trailing bits. */ +/** + * Decodes canonical RFC 4648 base32 without padding or ignored trailing bits. + * + * @param {string} input - Base32 text to decode. + * @param {number} expectedBytes - Exact decoded byte count. + * @returns {Uint8Array | undefined} Decoded bytes, or undefined for invalid input. + */ function decodeBase32(input: string, expectedBytes: number): Uint8Array | undefined { const decoded = new Uint8Array(expectedBytes); let accumulator = 0; @@ -35,10 +41,16 @@ function decodeBase32(input: string, expectedBytes: number): Uint8Array | undefi return decoded; } -/** Computes the CRC16-XModem checksum used by SEP-23 Strkeys. */ -function crc16Xmodem(payload: Uint8Array): number { +/** + * Computes the CRC16-XModem checksum used by SEP-23 Strkeys. + * + * @param {ArrayLike} payload - Bytes covered by the checksum. + * @returns {number} The unsigned 16-bit checksum. + */ +function crc16Xmodem(payload: ArrayLike): number { let checksum = 0; - for (const byte of payload) { + for (let index = 0; index < payload.length; index++) { + const byte = payload[index] ?? 0; checksum ^= byte << 8; for (let bit = 0; bit < 8; bit++) { checksum = checksum & 0x8000 ? ((checksum << 1) ^ 0x1021) & 0xffff : (checksum << 1) & 0xffff; diff --git a/src/chains/ton.ts b/src/chains/ton.ts index 3e93c09..63466cc 100644 --- a/src/chains/ton.ts +++ b/src/chains/ton.ts @@ -24,12 +24,15 @@ export class Ton extends Chain { * to be 0x00 (basechain) or 0xff (masterchain), the only two that exist. The * CRC stays unchecked: this is a format check. The raw `workchain:hex` form * is not accepted, because wallets and explorers exchange the friendly form. + * + * @param {string} address - Candidate TON user-friendly address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { if (!FRIENDLY_ADDRESS.test(address)) throw new InvalidAddressError(this.key, address); const binary = atob(address.replaceAll("-", "+").replaceAll("_", "/")); - const tag = binary.charCodeAt(0); - const workchain = binary.charCodeAt(1); + const tag = binary.codePointAt(0); + const workchain = binary.codePointAt(1); if ((tag !== 0x11 && tag !== 0x51) || (workchain !== 0x00 && workchain !== 0xff)) { throw new InvalidAddressError(this.key, address); } diff --git a/src/chains/tron.ts b/src/chains/tron.ts index 6f085a8..5b3c8e1 100644 --- a/src/chains/tron.ts +++ b/src/chains/tron.ts @@ -17,6 +17,9 @@ export class Tron extends Chain { * the other base58 chains out - Bitcoin's legacy form is the same 25 bytes * under 0x00 or 0x05, and a 34-character window would take both. The checksum * stays unchecked: this is a format check. + * + * @param {string} address - Candidate TRON address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 34); diff --git a/src/chains/xrpl.ts b/src/chains/xrpl.ts index 6c2f446..801caee 100644 --- a/src/chains/xrpl.ts +++ b/src/chains/xrpl.ts @@ -5,16 +5,26 @@ import { InvalidAddressError } from "../core/errors.js"; /** The ledger's base58 digits: Bitcoin's 58 characters reordered, so `r` is zero. */ export const XRP_ALPHABET = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; +function containsOnlyZeroes(bytes: ArrayLike, start: number, end: number): boolean { + for (let index = start; index < end; index++) { + if (bytes[index] !== 0) return false; + } + return true; +} + /** * An X-address folds a destination tag in: prefix, 20-byte account, flag byte, eight tag * bytes, checksum. XLS-5 requires the bytes the flag does not use to be zero, so flag 0 * leaves all eight and flag 1 the top four. TAG_64 is reserved and testnet is 0x04 0x93. + * + * @param {ArrayLike} decoded - Candidate decoded X-address bytes. + * @returns {boolean} Whether the bytes have a valid mainnet X-address envelope. */ -function isXAddress(decoded: Uint8Array): boolean { +function isXAddress(decoded: ArrayLike): boolean { if (decoded.length !== 35 || decoded[0] !== 0x05 || decoded[1] !== 0x44) return false; const flag = decoded[22]; if (flag !== 0 && flag !== 1) return false; - return decoded.subarray(flag === 0 ? 23 : 27, 31).every((byte) => byte === 0); + return containsOnlyZeroes(decoded, flag === 0 ? 23 : 27, 31); } export class Xrpl extends Chain { @@ -30,6 +40,9 @@ export class Xrpl extends Chain { * A classic address is Base58Check under version 0x00, 25 bytes, read under the * ledger's alphabet, which is what keeps Bitcoin and TRON out. The checksum stays * unchecked, and 48 characters is one past the longest address the format writes. + * + * @param {string} address - Candidate XRP Ledger address. + * @returns {string} The accepted address unchanged. */ override assertAddress(address: string): string { const decoded = decodeBase58(address, 48, XRP_ALPHABET); diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 60362d7..b476a19 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -8,6 +8,9 @@ import { ChainsError, getChain } from "../index.js"; * than a stack trace. Returns undefined once the exit code has been set. * * Imports go through the package entrypoint so the chain classes are registered. + * + * @param {string} input - Chain spelling supplied to the CLI. + * @returns {Chain | undefined} The resolved chain, or undefined after a known failure. */ export function resolveOrFail(input: string): Chain | undefined { try { diff --git a/src/core/base58.ts b/src/core/base58.ts index 1971c0f..c9d2b85 100644 --- a/src/core/base58.ts +++ b/src/core/base58.ts @@ -17,6 +17,11 @@ const BITCOIN_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuv * BigInt the next multiply has to walk, and a 100k-character string ties the * process up for seconds. An address format knows its maximum length, so the * caller states it and oversized input is rejected before any work. + * + * @param {string} input - Base58 text to decode. + * @param {number} maxLength - Maximum accepted character count. + * @param {string} alphabet - Ordered 58-character alphabet. + * @returns {Uint8Array | undefined} Decoded bytes, or undefined for invalid input. */ export function decodeBase58( input: string, diff --git a/src/core/chain.ts b/src/core/chain.ts index bb37c8c..9abd566 100644 --- a/src/core/chain.ts +++ b/src/core/chain.ts @@ -26,6 +26,8 @@ export abstract class Chain implements ChainInfo { * The base implementation only throws, so a chain that never overrode it cannot * answer address questions at all. Callers deserve to know that before they ask, * rather than by catching the failure. + * + * @returns {boolean} Whether this class overrides the base address validator. */ get validatesAddress(): boolean { return this.assertAddress !== Chain.prototype.assertAddress; diff --git a/src/core/errors.ts b/src/core/errors.ts index e9c54f8..7c40eb3 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -4,7 +4,7 @@ import type { ChainKey } from "./types.js"; /** Base class for failures surfaced through chains. */ export class ChainsError extends Error { - constructor(message: string, options?: ErrorOptions) { + constructor(message: string, options?: Readonly) { super(message, options); this.name = "ChainsError"; } diff --git a/src/core/identify.ts b/src/core/identify.ts index 3e5aeca..a1c48c1 100644 --- a/src/core/identify.ts +++ b/src/core/identify.ts @@ -16,6 +16,9 @@ export interface AddressMatches { * * A match is about format only. Every EVM chain shares one format, so a match * narrows the family rather than naming the chain an address is used on. + * + * @param {string} address - Address to check against every registered validator. + * @returns {AddressMatches} Matching chains and chains without a validator. */ export function identify(address: string): AddressMatches { const matches: Chain[] = []; diff --git a/src/core/resolve.ts b/src/core/resolve.ts index c0f50e9..075555a 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -46,6 +46,9 @@ const aliases: Readonly> = { * added later, and for renames, without anyone remembering to edit the alias table. * Symbols stay out of it: six chains report ETH, so indexing them would make the * answer depend on registration order. + * + * @param {string} name - Lowercase display name to resolve. + * @returns {ChainKey | undefined} Matching canonical key, if registered. */ function keyByName(name: string): ChainKey | undefined { for (const key of chains()) { diff --git a/src/core/text.ts b/src/core/text.ts index e6e639f..3327f1e 100644 --- a/src/core/text.ts +++ b/src/core/text.ts @@ -1,11 +1,21 @@ /** Guards caller text passes through before any surface prints it. */ -/** Blanks every character that steers rendering instead of carrying content. */ +/** + * Blanks every character that steers rendering instead of carrying content. + * + * @param {string} text - Caller-controlled text to sanitize. + * @returns {string} Text with rendering control characters replaced by spaces. + */ export function stripControlCharacters(text: string): string { return text.replaceAll(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " "); } -/** Quotes caller input, because a raw newline in an address writes its own answer line. */ +/** + * Quotes caller input, because a raw newline in an address writes its own answer line. + * + * @param {string} value - Caller-controlled value to quote. + * @returns {string} A JSON-quoted value safe for one output line. + */ export function quoted(value: string): string { return stripControlCharacters(JSON.stringify(value)); } diff --git a/src/mcp.ts b/src/mcp.ts index 86d524e..e36b739 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -22,7 +22,7 @@ interface ToolDefinition { title: string; description: string; inputSchema: TSchema; - execute(args: Record): ToolResult; + execute: (args: Readonly>) => ToolResult; } const chainArgument = Type.String({ @@ -31,7 +31,12 @@ const chainArgument = Type.String({ maxLength: 64, }); -/** One length contract for every address parameter; only the wording differs. */ +/** + * Builds one length contract for every address parameter; only the wording differs. + * + * @param {string} description - Surface-specific parameter description. + * @returns {TSchema} The shared bounded string schema. + */ function addressArgument(description: string): TSchema { return Type.String({ description, minLength: 1, maxLength: 256 }); } @@ -84,14 +89,25 @@ const tools: ToolDefinition[] = [ }, ]; -/** Formats the first TypeBox validation failure for an MCP client. */ +/** + * Formats the first TypeBox validation failure for an MCP client. + * + * @param {TSchema} schema - Schema that rejected the value. + * @param {unknown} value - Rejected input value. + * @returns {string} A client-facing validation message. + */ function validationError(schema: TSchema, value: unknown): string { const first = Value.Errors(schema, value)[0]; if (!first) return "Invalid arguments"; return `Invalid arguments at ${first.instancePath || "/"}: ${first.message}`; } -/** Keeps client-controlled error text from forging lines or terminal escapes. */ +/** + * Keeps client-controlled error text from forging lines or terminal escapes. + * + * @param {string} text - Error text to sanitize. + * @returns {CallToolResult} A sanitized MCP error result. + */ function errorResult(text: string): CallToolResult { return { content: [{ type: "text", text: stripControlCharacters(text) }], @@ -104,6 +120,9 @@ function errorResult(text: string): CallToolResult { * * `details` is dropped and `structuredContent` is never set: clients that see * structured output prefer it over `content` and would hide the readable answer. + * + * @param {ToolResult} result - Shared executor result. + * @returns {CallToolResult} The MCP text-result envelope. */ function toCallToolResult(result: ToolResult): CallToolResult { return { @@ -120,6 +139,8 @@ function toCallToolResult(result: ToolResult): CallToolResult { * does not implement Standard Schema, and this package's tool schemas are TypeBox, * shared with the Pi and OMP extensions. The high-level API would force a second * definition of every parameter. + * + * @returns {Server} An unconnected MCP server. */ export function createMcpServer(): Server { const toolsByName = new Map(tools.map((tool) => [tool.name, tool])); diff --git a/src/tool-operations.ts b/src/tool-operations.ts index 7ddc320..9be6ef7 100644 --- a/src/tool-operations.ts +++ b/src/tool-operations.ts @@ -83,7 +83,11 @@ export interface ToolResult
{ isError?: boolean; } -/** Keeps a failed resolution actionable by naming what the registry does hold. */ +/** + * Keeps a failed resolution actionable by naming what the registry does hold. + * + * @returns {string} Recovery guidance with the registered chain keys. + */ function resolutionHelp(): string { return `Known chain keys: ${chains().join(", ")}. Display names, symbols, and aliases such as matic or btc resolve too. Call chains_list for the whole registry.`; } @@ -91,7 +95,8 @@ function resolutionHelp(): string { /** * Resolves a key, name, symbol, or alias to its canonical chain metadata. * - * @param input - Chain key, name, symbol, or alias. + * @param {string} input - Chain key, name, symbol, or alias. + * @returns {ToolResult} Canonical metadata or recovery guidance. */ export function lookupChain(input: string): ToolResult { let chain: Chain; @@ -147,7 +152,8 @@ export function lookupChain(input: string): ToolResult} The rendered and structured chain listing. */ export function listChains(family?: string): ToolResult { const all = chains().map((key) => create(key)); @@ -199,7 +205,8 @@ export function listChains(family?: string): ToolResult { * rather than skipped, because a silent skip would let "matches none" read as * "belongs to no registered chain". * - * @param rawAddress - Address of unknown origin; surrounding whitespace is stripped first. + * @param {string} rawAddress - Address of unknown origin; surrounding whitespace is stripped. + * @returns {ToolResult} Matching and unchecked chain keys. */ export function identifyAddress(rawAddress: string): ToolResult { const address = rawAddress.trim(); @@ -239,8 +246,9 @@ export function identifyAddress(rawAddress: string): ToolResult} The rendered and structured validation outcome. */ export function validateChainAddress(input: string, rawAddress: string): ToolResult { // getChain trims the chain argument, so the address is trimmed to match. An diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index 7371ac1..8cb438d 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -1,4 +1,4 @@ -import { runCommand, type ArgsDef, type CommandDef } from "citty"; +import { runCommand } from "citty"; import consola from "consola"; import { afterEach, describe, expect, it, vi } from "vitest"; import list from "../../src/commands/list.ts"; @@ -9,14 +9,16 @@ const ESCAPE = String.fromCodePoint(27); const CSI = String.fromCodePoint(155); const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u; -/** Runs one subcommand and returns what it wrote through the given consola level. */ -async function capture( - command: CommandDef, - level: "error" | "warn", - rawArgs: string[], -): Promise { +/** + * Runs one subcommand and returns what it wrote through the given consola level. + * + * @param {"error" | "warn"} level - Consola method to capture. + * @param {() => Promise} execute - Bound command invocation. + * @returns {Promise} Text written through the selected level. + */ +async function capture(level: "error" | "warn", execute: () => Promise): Promise { const spy = vi.spyOn(consola, level).mockImplementation(() => {}); - await runCommand(command, { rawArgs }); + await execute(); return spy.mock.calls.map(([first]) => String(first)).join("\n"); } @@ -29,7 +31,9 @@ describe("CLI output escaping", () => { it("quotes a rejected address instead of letting it write its own line", async () => { const address = `1BadAddress\n${ESCAPE}[32m Valid Bitcoin address`; - const written = await capture(validate, "error", ["bitcoin", address]); + const written = await capture("error", () => + runCommand(validate, { rawArgs: ["bitcoin", address] }), + ); expect(written).not.toMatch(CONTROL); expect(written).toBe( @@ -38,21 +42,25 @@ describe("CLI output escaping", () => { }); it("blanks the C1 bytes JSON.stringify leaves alone in an unresolved chain", async () => { - const written = await capture(resolve, "error", [`doge${CSI}31m`]); + const written = await capture("error", () => + runCommand(resolve, { rawArgs: [`doge${CSI}31m`] }), + ); expect(written).not.toMatch(CONTROL); expect(written).toContain("Unsupported chain:"); }); it("quotes a family filter that matched nothing", async () => { - const written = await capture(list, "warn", ["--type", "evm\nutxo"]); + const written = await capture("warn", () => + runCommand(list, { rawArgs: ["--type", "evm\nutxo"] }), + ); expect(written).not.toMatch(CONTROL); expect(written).toBe('No registered chain has type: "evm\\nutxo"'); }); it("still exits 1 on a rejected address", async () => { - await capture(validate, "error", ["bitcoin", "not-an-address"]); + await capture("error", () => runCommand(validate, { rawArgs: ["bitcoin", "not-an-address"] })); expect(process.exitCode).toBe(1); }); diff --git a/test/unit/mcp.test.ts b/test/unit/mcp.test.ts index 8204c8b..9b6ff40 100644 --- a/test/unit/mcp.test.ts +++ b/test/unit/mcp.test.ts @@ -46,13 +46,10 @@ describe("chains MCP server", () => { }); expect(response.isError).not.toBe(true); - expect(response.content).toEqual([ - { - type: "text", - text: expect.stringContaining("Polygon PoS (polygon)"), - }, - ]); - const [part] = response.content as Array<{ text: string }>; + expect(response.content).toHaveLength(1); + const [part] = response.content as Array<{ type: string; text: string }>; + expect(part?.type).toBe("text"); + expect(part?.text).toContain("Polygon PoS (polygon)"); expect(part?.text).toContain("caip2: eip155:137"); expect(part?.text).toContain("chainId: 0x89"); }); @@ -80,9 +77,10 @@ describe("chains MCP server", () => { }); expect(response.isError).toBe(true); - expect(response.content).toEqual([ - { type: "text", text: expect.stringContaining("Invalid arguments") }, - ]); + expect(response.content).toHaveLength(1); + const [part] = response.content as Array<{ type: string; text: string }>; + expect(part?.type).toBe("text"); + expect(part?.text).toContain("Invalid arguments"); }); it("rejects prototype property names as unknown tools", async () => { @@ -117,9 +115,10 @@ describe("chains MCP server", () => { }); expect(response.isError).not.toBe(true); - expect(response.content).toEqual([ - { type: "text", text: expect.stringContaining("Valid Ethereum (ethereum) address") }, - ]); + expect(response.content).toHaveLength(1); + const [part] = response.content as Array<{ type: string; text: string }>; + expect(part?.type).toBe("text"); + expect(part?.text).toContain("Valid Ethereum (ethereum) address"); }); it("reports a rejected address as an answer, not a tool error", async () => { @@ -236,15 +235,15 @@ describe("chains MCP server", () => { const all = await client.callTool({ name: "chains_list", arguments: {} }); expect(all.isError).not.toBe(true); - const [listing] = all.content as Array<{ text: string }>; - expect(listing?.text).toContain("26 chains registered."); - expect(listing?.text).toContain("litecoin LTC utxo Litecoin"); - expect(listing?.text).toContain("cardano ADA utxo Cardano"); - expect(listing?.text).toContain("pepecoin PEP utxo Pepecoin"); - expect(listing?.text).toContain("ecash XEC utxo eCash"); - expect(listing?.text).toContain("bitcoin BTC utxo Bitcoin"); - expect(listing?.text).toContain("stellar XLM stellar Stellar"); - expect(listing?.text).toContain( + const listing = (all.content as Array<{ text: string }>).at(0)?.text; + expect(listing).toContain("26 chains registered."); + expect(listing).toContain("litecoin LTC utxo Litecoin"); + expect(listing).toContain("cardano ADA utxo Cardano"); + expect(listing).toContain("pepecoin PEP utxo Pepecoin"); + expect(listing).toContain("ecash XEC utxo eCash"); + expect(listing).toContain("bitcoin BTC utxo Bitcoin"); + expect(listing).toContain("stellar XLM stellar Stellar"); + expect(listing).toContain( "Families: evm, utxo, solana, stellar, xrpl, move, ton, tron, octra.", ); @@ -252,20 +251,20 @@ describe("chains MCP server", () => { name: "chains_list", arguments: { family: "move" }, }); - const [moves] = filtered.content as Array<{ text: string }>; - expect(moves?.text).toContain("2 registered move chains."); - expect(moves?.text).toContain("Aptos"); - expect(moves?.text).toContain("Sui"); - expect(moves?.text).not.toContain("Ethereum"); + const moves = (filtered.content as Array<{ text: string }>).at(0)?.text; + expect(moves).toContain("2 registered move chains."); + expect(moves).toContain("Aptos"); + expect(moves).toContain("Sui"); + expect(moves).not.toContain("Ethereum"); const unknown = await client.callTool({ name: "chains_list", arguments: { family: "rollup" }, }); expect(unknown.isError).toBe(true); - const [text] = unknown.content as Array<{ text: string }>; - expect(text?.text).toContain('Unknown chain family: "rollup"'); - expect(text?.text).toContain("Known families: evm"); + const text = (unknown.content as Array<{ text: string }>).at(0)?.text; + expect(text).toContain('Unknown chain family: "rollup"'); + expect(text).toContain("Known families: evm"); }); it("resolves a display name it printed itself", async () => { From 40b9b51ca4c885f5b0a74701c9905ee551840b4f Mon Sep 17 00:00:00 2001 From: Ori <18102267+oritwoen@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:53:45 +0200 Subject: [PATCH 3/3] chore: adopt @agntn/ox policy --- .oxfmtrc.json | 3 -- .oxlintrc.json | 3 -- oxfmt.config.ts | 7 ++++ oxlint.config.ts | 8 +++++ package.json | 10 +++--- pnpm-lock.yaml | 86 ++++++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 105 insertions(+), 12 deletions(-) delete mode 100644 .oxfmtrc.json delete mode 100644 .oxlintrc.json create mode 100644 oxfmt.config.ts create mode 100644 oxlint.config.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index 8a4fdaf..0000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ignorePatterns": ["dist", "coverage", "AGENTS.md"] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index ad31a9e..0000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ignorePatterns": ["dist", "coverage"] -} diff --git a/oxfmt.config.ts b/oxfmt.config.ts new file mode 100644 index 0000000..5a30436 --- /dev/null +++ b/oxfmt.config.ts @@ -0,0 +1,7 @@ +import oxfmt from "@agntn/ox/oxfmt"; +import { defineConfig } from "oxfmt"; + +export default defineConfig({ + ...oxfmt, + ignorePatterns: ["dist", "coverage", "AGENTS.md"], +}); diff --git a/oxlint.config.ts b/oxlint.config.ts new file mode 100644 index 0000000..0b2d157 --- /dev/null +++ b/oxlint.config.ts @@ -0,0 +1,8 @@ +import oxlint from "@agntn/ox/oxlint"; +import { defineConfig } from "oxlint"; + +export default defineConfig({ + ...oxlint, + rules: { ...oxlint.rules }, + ignorePatterns: ["dist", "coverage"], +}); diff --git a/package.json b/package.json index 5a9baeb..3f14ca6 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,10 @@ "scripts": { "build": "obuild", "dev": "obuild --stub", - "fmt": "oxlint . --fix && oxfmt --write .", - "fmt:check": "oxlint . && oxfmt --check .", - "lint": "oxlint .", - "lint:fix": "oxlint . --fix", + "fmt": "pnpm build && oxlint . --fix && oxfmt --write .", + "fmt:check": "pnpm build && oxlint . && oxfmt --check .", + "lint": "pnpm build && oxlint .", + "lint:fix": "pnpm build && oxlint . --fix", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit && pnpm build && tsc --noEmit -p tsconfig.extensions.json", @@ -57,12 +57,14 @@ "typebox": "1.3.15" }, "devDependencies": { + "@agntn/ox": "0.1.0", "@earendil-works/pi-coding-agent": "0.84.2", "@types/node": "26.2.0", "changelogen": "0.6.2", "obuild": "0.4.38", "oxfmt": "0.63.0", "oxlint": "1.78.0", + "oxlint-tsgolint": "7.0.2001", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f167918..af510c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: specifier: 1.3.15 version: 1.3.15 devDependencies: + '@agntn/ox': + specifier: 0.1.0 + version: 0.1.0(oxfmt@0.63.0)(oxlint-tsgolint@7.0.2001)(oxlint@1.78.0(oxlint-tsgolint@7.0.2001)) '@earendil-works/pi-coding-agent': specifier: 0.84.2 version: 0.84.2(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) @@ -38,7 +41,10 @@ importers: version: 0.63.0 oxlint: specifier: 1.78.0 - version: 1.78.0 + version: 1.78.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: + specifier: 7.0.2001 + version: 7.0.2001 typescript: specifier: 7.0.2 version: 7.0.2 @@ -48,6 +54,14 @@ importers: packages: + '@agntn/ox@0.1.0': + resolution: {integrity: sha512-bYyCMczypvi/bxDgK1gOFhOnGFAqTNnTTWl+yIocaP68wsfGtwZwxN9Rld1bMcLXRVlO9PJCl7EASqToz8dOVw==} + engines: {node: '>=24'} + peerDependencies: + oxfmt: '>=0.63.0' + oxlint: '>=1.78.0' + oxlint-tsgolint: '>=7.0.2001' + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -569,6 +583,36 @@ packages: cpu: [x64] os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.78.0': resolution: {integrity: sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1893,6 +1937,10 @@ packages: vite-plus: optional: true + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} + hasBin: true + oxlint@1.78.0: resolution: {integrity: sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2314,6 +2362,12 @@ packages: snapshots: + '@agntn/ox@0.1.0(oxfmt@0.63.0)(oxlint-tsgolint@7.0.2001)(oxlint@1.78.0(oxlint-tsgolint@7.0.2001))': + dependencies: + oxfmt: 0.63.0 + oxlint: 1.78.0(oxlint-tsgolint@7.0.2001) + oxlint-tsgolint: 7.0.2001 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': dependencies: json-schema-to-ts: 3.1.1 @@ -2845,6 +2899,24 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.63.0': optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2001': + optional: true + '@oxlint/binding-android-arm-eabi@1.78.0': optional: true @@ -3906,7 +3978,16 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.63.0 '@oxfmt/binding-win32-x64-msvc': 0.63.0 - oxlint@1.78.0: + oxlint-tsgolint@7.0.2001: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.78.0(oxlint-tsgolint@7.0.2001): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.78.0 '@oxlint/binding-android-arm64': 1.78.0 @@ -3927,6 +4008,7 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.78.0 '@oxlint/binding-win32-ia32-msvc': 1.78.0 '@oxlint/binding-win32-x64-msvc': 1.78.0 + oxlint-tsgolint: 7.0.2001 p-retry@4.6.2: dependencies: