diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17b54b7f0..705faeb5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run knip + - run: npm run lint:dead duplicates: name: Duplicate Code (jscpd) @@ -89,7 +89,7 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm test -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml + - run: npm run test:coverage -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.husky/pre-commit b/.husky/pre-commit index 4ae345c15..f3f510d27 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ -npx oxlint --fix +npx lint-staged +npm run typecheck diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f6821cbe..8b7fd4426 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,18 @@ npm run verify:package npm pack --dry-run ``` +`npm install` prepares the Husky pre-commit hook. Commits format and lint staged +files with lint-staged, then typecheck the complete project. The normal test and +CI coverage runs enforce the thresholds in `vitest.config.ts`; `npm run +lint:dead` rejects unused files, exports, and dependencies. + +Set `REA_LOG_LEVEL` to `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or +`silent` to control structured JSON diagnostics. MCP mode defaults to `info` and +always writes logs to stderr so the stdio protocol remains intact. One-shot CLI +logging is opt-in and writes to stdout when a level is configured, preserving +machine-readable command output by default. Request arguments, bridge +authentication tokens, and environment data are redacted. + Changes that claim real Hopper behavior must also be tested against two distinct binaries: ```bash diff --git a/knip.json b/knip.json index 5fe7a94e2..bdff845d3 100644 --- a/knip.json +++ b/knip.json @@ -15,15 +15,15 @@ "ignore": ["dist/**", "docs/**", ".codex/**", ".devin/**"], "ignoreDependencies": [], "rules": { - "files": "warn", - "dependencies": "warn", - "devDependencies": "warn", + "files": "error", + "dependencies": "error", + "devDependencies": "error", "unlisted": "error", - "exports": "warn", - "nsExports": "warn", - "types": "warn", - "nsTypes": "warn", - "enumMembers": "warn", + "exports": "error", + "nsExports": "error", + "types": "error", + "nsTypes": "error", + "enumMembers": "error", "duplicates": "error" } } diff --git a/package-lock.json b/package-lock.json index 0ea8d72fd..50535a5bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@modelcontextprotocol/server": "2.0.0-beta.3", "incur": "0.4.13", + "pino": "^9.14.0", "zod": "4.4.3" }, "bin": { @@ -23,6 +24,7 @@ "husky": "9.1.7", "jscpd": "5.0.12", "knip": "6.26.0", + "lint-staged": "^16.4.0", "oxlint": "1.73.0", "prettier": "3.6.2", "typedoc": "0.28.20", @@ -100,24 +102,26 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -515,6 +519,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", @@ -1193,6 +1220,12 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -1416,6 +1449,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -1735,6 +1791,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1764,6 +1862,15 @@ "js-tokens": "^10.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1774,6 +1881,56 @@ "node": ">=18" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1806,6 +1963,13 @@ "node": ">=8" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1819,6 +1983,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -1836,6 +2013,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -1928,6 +2112,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.14.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", @@ -1996,6 +2193,22 @@ "node": ">=22" } }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2494,6 +2707,85 @@ "uc.micro": "^2.0.0" } }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -2574,6 +2866,19 @@ "dev": true, "license": "MIT" }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -2607,6 +2912,31 @@ "node": ">=12.20.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oxc-parser": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", @@ -2772,6 +3102,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2827,6 +3194,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -2837,6 +3220,21 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2847,6 +3245,30 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2881,6 +3303,15 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2924,6 +3355,36 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/smol-toml": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", @@ -2937,6 +3398,15 @@ "url": "https://github.com/sponsors/cyyynthia" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2947,6 +3417,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2961,6 +3440,49 @@ "dev": true, "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -2987,6 +3509,15 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3357,6 +3888,42 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index f1c58772d..d230a7a1e 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "lint": "oxlint", "lint:fix": "oxlint --fix", "knip": "knip", + "lint:dead": "knip", "jscpd": "jscpd src/ tests/", "scan:todos": "node scripts/scan-todos.mjs", "start": "node dist/main.js", "pretest": "npm run build", "test": "vitest run", + "pretest:coverage": "npm run build", "test:coverage": "vitest run --coverage.enabled --coverage.provider=v8", "test:verbose": "vitest run --reporter=verbose", "test:watch": "vitest", @@ -60,9 +62,14 @@ "prepack": "npm run build", "prepare": "husky" }, + "lint-staged": { + "*.{ts,mjs,json,md,yml,yaml}": "prettier --write", + "*.ts": "oxlint --fix" + }, "dependencies": { "@modelcontextprotocol/server": "2.0.0-beta.3", "incur": "0.4.13", + "pino": "^9.14.0", "zod": "4.4.3" }, "devDependencies": { @@ -72,6 +79,7 @@ "husky": "9.1.7", "jscpd": "5.0.12", "knip": "6.26.0", + "lint-staged": "^16.4.0", "oxlint": "1.73.0", "prettier": "3.6.2", "typedoc": "0.28.20", diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 6a06f75fb..6f06e7932 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -1,6 +1,7 @@ import { parseConfig } from "../config.js"; import type { JsonValue } from "../hopper/protocol.js"; import { createBinarySession } from "./runtime.js"; +import { silentLogger, type Logger } from "../logger.js"; /** * Open one binary, execute one tool, and always release the bridge session. @@ -11,11 +12,12 @@ export const runDirectAnalysis = async ( path: string, tool: "binary_overview" | "procedure_pseudo_code", arguments_: Readonly>, + logger: Logger = silentLogger, ): Promise => { const config = parseConfig(process.env); if (!config.ok) return { error: config.error._tag, message: config.error.message }; - const session = createBinarySession(config.value); + const session = createBinarySession(config.value, logger); try { const opened = await session.open(path); if (!opened.ok) diff --git a/src/application/runtime.ts b/src/application/runtime.ts index 49c32aabd..bed304462 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -4,13 +4,17 @@ import type { AppConfig } from "../config.js"; import { BinarySession } from "./BinarySession.js"; import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js"; import { HopperClient } from "../hopper/HopperClient.js"; +import { silentLogger, type Logger } from "../logger.js"; /** * Compose the target-switching runtime shared directly by CLI and MCP adapters. * This is the sole production wiring point for Hopper so neither adapter shells * out to the other and both retain identical loader and bridge semantics. */ -export const createBinarySession = (config: AppConfig): BinarySession => { +export const createBinarySession = ( + config: AppConfig, + logger: Logger = silentLogger, +): BinarySession => { const bridgeScriptPath = fileURLToPath( new URL("../../bridge/hopper_bridge.py", import.meta.url), ); @@ -27,6 +31,7 @@ export const createBinarySession = (config: AppConfig): BinarySession => { : target.loaderArgs, bridgeScriptPath, }), + logger: logger.child({ layer: "bridge" }), }), ); }; diff --git a/src/cli.ts b/src/cli.ts index 17948aa7a..0b1929ca6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { runDoctor } from "./application/Doctor.js"; import { runDirectAnalysis } from "./application/DirectAnalysis.js"; import { runSetup } from "./application/Setup.js"; import { PRODUCT_IDENTITY } from "./identity.js"; +import { createLogger, parseLogLevel, type Logger } from "./logger.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -12,6 +13,12 @@ import { PRODUCT_IDENTITY } from "./identity.js"; * `--mcp` are intercepted by the executable dispatcher before this module loads. */ export const createCli = (): ReturnType => { + const logger = createLogger( + "cli", + process.env.REA_LOG_LEVEL === undefined + ? "silent" + : parseLogLevel(process.env.REA_LOG_LEVEL), + ); const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, { version: "0.1.0", description: "Install, diagnose, and automate Hopper binary analysis.", @@ -40,21 +47,26 @@ export const createCli = (): ReturnType => { .describe("Approve prerequisite installation"), }), alias: { yes: "y" }, - run: ({ options }) => runSetup(options.yes), + run: ({ options }) => + logCliCommand(logger, "setup", () => runSetup(options.yes)), }); cli.command("doctor", { description: "Diagnose the local Hopper integration", options: z.object({ target: z.string().optional().describe("Optional binary path to check"), }), - run: ({ options }) => runDoctor(options.target), + run: ({ options }) => + logCliCommand(logger, "doctor", () => runDoctor(options.target)), }); cli.command("analyze", { description: "Analyze a binary through the shared Hopper core", args: z.object({ path: z.string().describe("Binary or Hopper database path"), }), - run: ({ args }) => runDirectAnalysis(args.path, "binary_overview", {}), + run: ({ args }) => + logCliCommand(logger, "analyze", () => + runDirectAnalysis(args.path, "binary_overview", {}, logger), + ), }); cli.command("decompile", { description: "Decompile an address through the shared Hopper core", @@ -63,9 +75,44 @@ export const createCli = (): ReturnType => { address: z.string().describe("Procedure address"), }), run: ({ args }) => - runDirectAnalysis(args.path, "procedure_pseudo_code", { - procedure: args.address, - }), + logCliCommand(logger, "decompile", () => + runDirectAnalysis( + args.path, + "procedure_pseudo_code", + { procedure: args.address }, + logger, + ), + ), }); return cli; }; + +const logCliCommand = async ( + logger: Logger, + command: string, + execute: () => Promise, +): Promise => { + const startedAt = performance.now(); + try { + const value = await execute(); + logger.info( + { + command, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: "ok", + }, + "CLI command completed", + ); + return value; + } catch (cause: unknown) { + logger.error( + { + command, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: "error", + }, + "CLI command failed", + ); + throw cause; + } +}; diff --git a/src/config.ts b/src/config.ts index 544e22cab..bc55194b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { ConfigurationError } from "./domain/errors.js"; import { err, ok, type Result } from "./domain/result.js"; +import type { LogLevel } from "./logger.js"; const DEFAULT_HOPPER_LAUNCHER_PATH = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; @@ -11,6 +12,7 @@ export interface AppConfig { readonly hopperTargetPath: string | undefined; readonly hopperTargetKind: "executable" | "database"; readonly hopperLoaderArgs: readonly string[]; + readonly logLevel: LogLevel; } const environmentSchema = z.object({ @@ -18,6 +20,9 @@ const environmentSchema = z.object({ HOPPER_TARGET_PATH: z.string().min(1).optional(), HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"), HOPPER_LOADER_ARGS_JSON: z.string().optional(), + REA_LOG_LEVEL: z + .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) + .default("info"), }); /** @@ -66,5 +71,6 @@ export const parseConfig = ( hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH, hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND, hopperLoaderArgs: parsedArgs.data, + logLevel: parsedEnvironment.data.REA_LOG_LEVEL, }); }; diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index cf6fd535f..f64ec054b 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -14,6 +14,7 @@ import { } from "../domain/errors.js"; import { err, ok, type Result } from "../domain/result.js"; import type { BridgeLaunch, BridgeLauncher } from "./BridgeLauncher.js"; +import { silentLogger, type Logger } from "../logger.js"; import { parseResponseLine, responseResult, @@ -29,6 +30,7 @@ export interface HopperClientOptions { readonly requestTimeoutMs?: number; readonly startupTimeoutMs?: number; readonly onDiagnostic?: (event: HopperDiagnostic) => void; + readonly logger?: Logger; } /** Safe launcher telemetry; stderr content is intentionally never exposed. */ @@ -62,6 +64,7 @@ export class HopperClient { > & HopperClientOptions; readonly #pending = new Map(); + readonly #logger: Logger; #socket: Socket | undefined; #launch: BridgeLaunch | undefined; #directory: string | undefined; @@ -73,6 +76,7 @@ export class HopperClient { #closePromise: Promise | undefined; constructor(options: HopperClientOptions) { + this.#logger = options.logger ?? silentLogger; this.#options = { ...options, requestTimeoutMs: options.requestTimeoutMs ?? 30_000, @@ -224,6 +228,7 @@ export class HopperClient { if (options.signal?.aborted === true) return err(new HopperCancelledError()); const id = this.#nextId++; + const startedAt = performance.now(); const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs; const response = new Promise>((resolve) => { const onAbort = @@ -252,7 +257,17 @@ export class HopperClient { this.#settle(id, err(new HopperProcessError(null))); }, ); - return response; + const result = await response; + this.#logger[result.ok ? "debug" : "warn"]( + { + method, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: result.ok ? "ok" : "error", + ...(result.ok ? {} : { errorTag: result.error._tag }), + }, + "Hopper bridge request completed", + ); + return result; } #attachSocket(socket: Socket): void { diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 000000000..bb412db9f --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,41 @@ +import pino, { type Logger } from "pino"; +import { z } from "zod"; + +/** Supported structured log levels at the process boundary. */ +export type LogLevel = z.infer; + +const logLevelSchema = z + .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) + .default("info"); + +/** Parse the optional REA_LOG_LEVEL environment variable. */ +export const parseLogLevel = (value: unknown): LogLevel => + logLevelSchema.parse(value); + +/** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */ +export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger => + pino( + { + level, + base: { application: "rea", mode }, + redact: { + paths: [ + "token", + "*.token", + "params", + "*.params", + "arguments", + "*.arguments", + "environment", + "*.environment", + ], + censor: "[Redacted]", + }, + }, + pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }), + ); + +/** Logger used by embedders that have not opted into process output. */ +export const silentLogger = pino({ level: "silent" }); + +export type { Logger }; diff --git a/src/main.ts b/src/main.ts index 62e54f1d3..1376ff0fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { import { parseConfig } from "./config.js"; import { createBinarySession } from "./application/runtime.js"; import { createServer } from "./server/createServer.js"; +import { createLogger } from "./logger.js"; /** * Start the long-lived MCP adapter and install idempotent shutdown handlers. @@ -23,27 +24,35 @@ export const run = async (): Promise => { process.stderr.write(`${config.error._tag}: ${config.error.message}\n`); return 1; } + const logger = createLogger("mcp", config.value.logLevel); + const serverLogger = logger.child({ layer: "server" }); - const session = createBinarySession(config.value); + const session = createBinarySession(config.value, logger); if (config.value.hopperTargetPath !== undefined) { const opened = await session.open(config.value.hopperTargetPath, { targetKind: config.value.hopperTargetKind, }); if (!opened.ok) { await session.close(); + serverLogger.error( + { errorTag: opened.error._tag }, + "Initial target failed to open", + ); process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`); return 1; } } let handle: StdioServerHandle; try { - handle = serveStdio(() => createServer(session, session), { + handle = serveStdio(() => createServer(session, session, logger), { onerror: () => { + serverLogger.error("MCP stdio transport error"); process.stderr.write("MCP stdio transport error\n"); }, }); } catch { await session.close(); + serverLogger.error("Failed to start MCP stdio transport"); process.stderr.write("Failed to start MCP stdio transport\n"); return 1; } @@ -59,6 +68,7 @@ export const run = async (): Promise => { const requestShutdown = (): void => { shutdown().catch(() => { process.exitCode = 1; + serverLogger.error("Failed to shut down cleanly"); process.stderr.write("Failed to shut down cleanly\n"); }); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index bdc69ff7b..9e4a08f31 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -6,6 +6,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { registerEnhancedTools } from "./registerEnhancedTools.js"; import { registerOfficialTools } from "./registerOfficialTools.js"; import { registerSessionTools } from "./registerSessionTools.js"; +import { silentLogger, type Logger } from "../logger.js"; /** * Construct one MCP server without acquiring subprocess resources. @@ -15,6 +16,7 @@ import { registerSessionTools } from "./registerSessionTools.js"; export const createServer = ( hopper: HopperToolPort, session?: BinarySession, + logger: Logger = silentLogger, ): McpServer => { const server = new McpServer( { name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" }, @@ -26,8 +28,9 @@ export const createServer = ( : "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.", }, ); - registerOfficialTools(server, hopper); - registerEnhancedTools(server, hopper); - if (session !== undefined) registerSessionTools(server, session); + const toolLogger = logger.child({ layer: "server" }); + registerOfficialTools(server, hopper, toolLogger); + registerEnhancedTools(server, hopper, toolLogger); + if (session !== undefined) registerSessionTools(server, session, toolLogger); return server; }; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 099d825aa..ab4263950 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -8,15 +8,18 @@ import { type ToolContract, } from "../contracts/toolContracts.js"; import { toCallToolResult } from "./toolResult.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; /** Register composed workflows against the same port as direct bridge tools. */ export const registerEnhancedTools = ( server: McpServer, hopper: HopperToolPort, + logger: Logger, ): void => { const services = new EnhancedTools(hopper); for (const contract of ENHANCED_TOOL_CONTRACTS) { - registerEnhancedTool(server, services, contract); + registerEnhancedTool(server, services, contract, logger); } }; @@ -24,6 +27,7 @@ const registerEnhancedTool = ( server: McpServer, services: EnhancedTools, contract: ToolContract, + logger: Logger, ): void => { const name = enhancedToolNameSchema.parse(contract.name); server.registerTool( @@ -31,7 +35,9 @@ const registerEnhancedTool = ( { description: contract.description, inputSchema: contract.inputSchema }, async (input, context) => toCallToolResult( - await services.execute(name, input, context.mcpReq.signal), + await logToolExecution(logger, name, () => + services.execute(name, input, context.mcpReq.signal), + ), ), ); }; diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts index 2254d77db..42b011ebe 100644 --- a/src/server/registerOfficialTools.ts +++ b/src/server/registerOfficialTools.ts @@ -7,14 +7,17 @@ import { } from "../contracts/toolContracts.js"; import { jsonValueSchema, type JsonValue } from "../hopper/protocol.js"; import { toCallToolResult } from "./toolResult.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; /** Register direct bridge proxies, preserving MCP cancellation and typed errors. */ export const registerOfficialTools = ( server: McpServer, hopper: HopperToolPort, + logger: Logger, ): void => { for (const contract of OFFICIAL_TOOL_CONTRACTS) { - registerOfficialTool(server, hopper, contract); + registerOfficialTool(server, hopper, contract, logger); } }; @@ -22,6 +25,7 @@ const registerOfficialTool = ( server: McpServer, hopper: HopperToolPort, contract: ToolContract, + logger: Logger, ): void => { server.registerTool( contract.name, @@ -31,9 +35,11 @@ const registerOfficialTool = ( }, async (input, context) => { const arguments_ = projectOfficialArguments(contract, input); - const result = await hopper.callTool(contract.name, arguments_, { - signal: context.mcpReq.signal, - }); + const result = await logToolExecution(logger, contract.name, () => + hopper.callTool(contract.name, arguments_, { + signal: context.mcpReq.signal, + }), + ); return toCallToolResult(result); }, ); diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index cdd39efaf..50c361906 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -4,11 +4,14 @@ import { z } from "zod"; import type { BinarySession } from "../application/BinarySession.js"; import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js"; import { toCallToolResult } from "./toolResult.js"; +import type { Logger } from "../logger.js"; +import { logToolExecution } from "./toolLogging.js"; /** Register MCP-only target lifecycle operations on a long-lived session. */ export const registerSessionTools = ( server: McpServer, session: BinarySession, + logger: Logger, ): void => { const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS; server.registerTool( @@ -19,9 +22,9 @@ export const registerSessionTools = ( }, async (input, context) => { const parsed = z.object({ path: z.string().min(1) }).parse(input); - const opened = await session.open(parsed.path, { - signal: context.mcpReq.signal, - }); + const opened = await logToolExecution(logger, openContract.name, () => + session.open(parsed.path, { signal: context.mcpReq.signal }), + ); return opened.ok ? toCallToolResult({ ok: true, @@ -41,7 +44,12 @@ export const registerSessionTools = ( description: closeContract.description, inputSchema: closeContract.inputSchema, }, - async () => toCallToolResult(await session.close()), + async () => + toCallToolResult( + await logToolExecution(logger, closeContract.name, () => + session.close(), + ), + ), ); server.registerTool( statusContract.name, diff --git a/src/server/toolLogging.ts b/src/server/toolLogging.ts new file mode 100644 index 000000000..1c3f95448 --- /dev/null +++ b/src/server/toolLogging.ts @@ -0,0 +1,21 @@ +import type { Result } from "../domain/result.js"; +import type { Logger } from "../logger.js"; + +/** Log one MCP tool's duration and status without recording caller input. */ +export const logToolExecution = async ( + logger: Logger, + tool: string, + execute: () => Promise>, +): Promise> => { + const startedAt = performance.now(); + const result = await execute(); + logger[result.ok ? "info" : "warn"]( + { + tool, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + status: result.ok ? "ok" : "error", + }, + "MCP tool execution completed", + ); + return result; +}; diff --git a/tests/config.test.ts b/tests/config.test.ts index 1cbcbd272..bc765dd8f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,6 +13,7 @@ describe("runtime configuration", () => { expect(result.value.hopperLauncherPath).toContain("/MacOS/hopper"); expect(result.value.hopperTargetKind).toBe("executable"); expect(result.value.hopperLoaderArgs).toEqual([]); + expect(result.value.logLevel).toBe("info"); } }); @@ -31,6 +32,7 @@ describe("runtime configuration", () => { hopperTargetPath: "/fixture/sample.hop", hopperTargetKind: "database", hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"], + logLevel: "info", }, }); }); @@ -46,4 +48,10 @@ describe("runtime configuration", () => { ).toBe(false); }, ); + + it("parses supported log levels and rejects unknown levels", () => { + const configured = parseConfig({ REA_LOG_LEVEL: "debug" }); + expect(configured.ok && configured.value.logLevel).toBe("debug"); + expect(parseConfig({ REA_LOG_LEVEL: "verbose" }).ok).toBe(false); + }); }); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 151b77f7d..342b30636 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -42,7 +42,22 @@ describe("production stdio runtime", () => { await client.close(); await transport.close(); } - expect(stderr).toBe(""); + const records = stderr + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line: string): unknown => JSON.parse(line)); + expect(records).toContainEqual( + expect.objectContaining({ + application: "rea", + mode: "mcp", + layer: "server", + tool: "current_document", + status: "ok", + }), + ); + expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON"); + expect(stderr).not.toContain(fixturePath); }); it("honors the configured kind for an initial database target", async () => {