diff --git a/eslint.config.ts b/eslint.config.ts index 402426f..ef67c18 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,4 +1,5 @@ import { exadevConfig } from "@exadev/eslint-config"; +import { defineConfig } from "eslint/config"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import globals from "globals"; import type { Rule } from "eslint"; @@ -23,12 +24,13 @@ const noStrykerDisable: Rule.RuleModule = { }); } } + return {}; }, }; -export default exadevConfig( - {}, +export default defineConfig( + ...exadevConfig(), { ignores: [ "dist", @@ -50,6 +52,7 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts}"], rules: { "@typescript-eslint/consistent-type-imports": [ "error", @@ -58,16 +61,42 @@ export default exadevConfig( }, }, { + files: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], plugins: { local: { rules: { "no-stryker-disable": noStrykerDisable } }, }, rules: { "local/no-stryker-disable": "error" }, }, /* Test fixtures legitimately encode raw protocol values (16-byte tokens, - 24-hex hop ids, chain lengths); naming them would obscure the fixture. */ + 24-hex hop ids, chain lengths); naming them would obscure the fixture. + Polling for an event and draining a stream are sequential by definition: + each iteration depends on the observation made by the previous one. */ { files: ["**/*.test.ts"], - rules: { "@typescript-eslint/no-magic-numbers": "off" }, + rules: { + "@typescript-eslint/no-magic-numbers": "off", + "no-await-in-loop": "off", + }, + }, + /* readLines is an async generator over a socket: each line is awaited + before the next can be produced, so the loop cannot be parallelised. */ + { + files: ["src/adapters/node/uds-transport.ts"], + rules: { "no-await-in-loop": "off" }, + }, + /* Registry entries are read one at a time so the listing order and the + number of open file handles stay bounded by one, whatever the directory + holds. */ + { + files: ["src/adapters/node/fs-registry-store.ts"], + rules: { "no-await-in-loop": "off" }, + }, + /* The spool sweep stats and unlinks one file at a time so removal order + and open handles stay bounded by one, and the first failed unlink stops + the pass exactly as before. */ + { + files: ["src/domain/file-transfer.ts"], + rules: { "no-await-in-loop": "off" }, }, /* defineSchema attaches a guard to a live Zod class instance; spread would destroy the prototype, so Object.assign is the only correct tool there. */ diff --git a/package.json b/package.json index e890bab..861a9b7 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,54 @@ { "name": "cc-peer", - "version": "1.6.5", "description": "Communicate with local Claude Code instances over their native cross-session peer messaging: send and receive messages, register as a named discoverable peer, receipts, idle subscriptions, and a REST facade via `npx cc-peer`.", - "type": "module", - "license": "MIT", + "version": "1.6.5", "author": "Joseph Mearman ", - "repository": { - "type": "git", - "url": "git+https://github.com/ExaDev/cc-peer.git" + "bin": { + "cc-peer": "./dist/bin/cc-peer.mjs" }, - "homepage": "https://github.com/ExaDev/cc-peer#readme", "bugs": { "url": "https://github.com/ExaDev/cc-peer/issues" }, - "sideEffects": false, + "dependencies": { + "zod": "4.5.4" + }, + "devDependencies": { + "@anthropic-ai/claude-code": "2.1.261", + "@arethetypeswrong/cli": "0.18.5", + "@commitlint/config-conventional": "21.2.2", + "@commitlint/types": "21.2.0", + "@exadev/eslint-config": "3.0.0", + "@semantic-release/changelog": "7.0.0", + "@semantic-release/commit-analyzer": "13.0.1", + "@semantic-release/exec": "7.1.0", + "@semantic-release/git": "11.0.1", + "@semantic-release/github": "12.0.9", + "@semantic-release/npm": "13.1.5", + "@semantic-release/release-notes-generator": "14.1.1", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", + "@types/node": "26.4.1", + "@vitest/coverage-v8": "4.1.11", + "commitlint": "21.2.2", + "eslint": "10.10.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-prettier": "5.5.6", + "globals": "17.12.0", + "husky": "9.1.7", + "jiti": "2.7.0", + "lint-staged": "17.5.0", + "prettier": "3.9.6", + "publint": "0.3.24", + "semantic-release": "25.0.9", + "tsdown": "0.23.0", + "tsx": "4.23.13", + "turbo": "2.11.5", + "typescript": "6.0.3", + "vitest": "4.1.11" + }, "engines": { "node": ">=20" }, - "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", - "bin": { - "cc-peer": "./dist/bin/cc-peer.mjs" - }, "exports": { ".": { "import": { @@ -45,69 +73,41 @@ "./schemas/*.schema.json": "./schemas/*.schema.json", "./package.json": "./package.json" }, - "main": "./dist/cc-peer.cjs", - "types": "./dist/cc-peer.d.mts", "files": [ "dist", "schemas", "docs" ], + "homepage": "https://github.com/ExaDev/cc-peer#readme", + "license": "MIT", + "main": "./dist/cc-peer.cjs", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "publishConfig": { "provenance": true, "access": "public" }, + "repository": { + "type": "git", + "url": "git+https://github.com/ExaDev/cc-peer.git" + }, "scripts": { - "build": "turbo run _build", "_build": "tsdown && tsx scripts/generate-json-schema.ts", - "lint": "turbo run _lint", "_lint": "eslint . --cache --fix", - "typecheck": "turbo run _typecheck", "_typecheck": "tsc --noEmit", + "build": "turbo run _build", + "lint": "turbo run _lint", + "prepare": "husky", + "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack --profile node16", "test": "vitest run", - "test:watch": "vitest", - "test:unit": "vitest run --project unit", - "test:integration": "vitest run --project integration", + "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:integration": "vitest run --project integration", "test:mutation": "stryker run stryker.config.ts", - "prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm build && publint && attw --pack --profile node16", - "prepare": "husky", - "test:coverage": "vitest run --coverage" - }, - "dependencies": { - "zod": "4.5.4" + "test:unit": "vitest run --project unit", + "test:watch": "vitest", + "typecheck": "turbo run _typecheck" }, - "devDependencies": { - "@anthropic-ai/claude-code": "2.1.261", - "@arethetypeswrong/cli": "0.18.5", - "@commitlint/config-conventional": "21.2.2", - "@commitlint/types": "21.2.0", - "@exadev/eslint-config": "2.12.1", - "@semantic-release/changelog": "7.0.0", - "@semantic-release/commit-analyzer": "13.0.1", - "@semantic-release/exec": "7.1.0", - "@semantic-release/git": "11.0.1", - "@semantic-release/github": "12.0.9", - "@semantic-release/npm": "13.1.5", - "@semantic-release/release-notes-generator": "14.1.1", - "@stryker-mutator/core": "10.0.0", - "@stryker-mutator/vitest-runner": "10.0.0", - "@types/node": "26.4.1", - "@vitest/coverage-v8": "4.1.11", - "commitlint": "21.2.2", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", - "globals": "17.12.0", - "husky": "9.1.7", - "jiti": "2.7.0", - "lint-staged": "17.5.0", - "prettier": "3.9.6", - "publint": "0.3.24", - "semantic-release": "25.0.9", - "tsdown": "0.23.0", - "tsx": "4.23.13", - "turbo": "2.11.5", - "typescript": "6.0.3", - "vitest": "4.1.11" - } + "sideEffects": false, + "type": "module", + "types": "./dist/cc-peer.d.mts" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb78c1b..93eddb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,8 +188,8 @@ importers: specifier: 21.2.0 version: 21.2.0 '@exadev/eslint-config': - specifier: 2.12.1 - version: 2.12.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) + specifier: 3.0.0 + version: 3.0.0(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3) '@semantic-release/changelog': specifier: 7.0.0 version: 7.0.0(semantic-release@25.0.9(supports-color@7.2.0)(typescript@6.0.3)) @@ -850,6 +850,10 @@ packages: eslint: optional: true + '@eslint/json@2.1.1': + resolution: {integrity: sha512-ffVzPZALaJzxuo46ggBtJ9c8YBWKLMh/R35aFBHk/dRobmFNortJlHJfBWbHGuydoFeVZbvPy0PLX2wvCI9T9w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -858,26 +862,38 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@exadev/eslint-config@2.12.1': - resolution: {integrity: sha512-b5JKCX7l5onZy0i0kHdIeuiocxDoiuEwnrG6+j+EV8ZWaLpi18BDzNi1iT3tc647DFRUYJtHWDiJrkWT6E2JjQ==} - engines: {node: '>=20'} + '@exadev/eslint-config@3.0.0': + resolution: {integrity: sha512-nvyOjjM1sGKjQo6buZt69vJ5ND0CuE/lxoDvR2oRgv74qj4gJBZr10d6Y/QeTd6LQIPDkPPnH78YYfX/HSzNJA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: + '@eslint/json': ^2.1.0 + '@eslint/markdown': ^8.0.3 '@next/eslint-plugin-next': ^16.3.2 + '@vitest/eslint-plugin': ^1.6.27 eslint: '>=10.0.0' eslint-plugin-jsx-a11y: ^6.10.2 eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.1.1 + eslint-plugin-turbo: ^2.3.1 typescript: '>=4.8.4' typescript-eslint: '>=8.0.0' peerDependenciesMeta: + '@eslint/json': + optional: true + '@eslint/markdown': + optional: true '@next/eslint-plugin-next': optional: true + '@vitest/eslint-plugin': + optional: true eslint-plugin-jsx-a11y: optional: true eslint-plugin-react: optional: true eslint-plugin-react-hooks: optional: true + eslint-plugin-turbo: + optional: true '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -895,6 +911,10 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + '@humanwhocodes/momoa@3.3.13': + resolution: {integrity: sha512-AbKq3EW41+HY6pNMJgAOrz68iyiTTf0Nr1JRHHfuG5zwniJ9Dw8J3U7oa1I2ptpqYwmEf1sXKOGK6lbdZ9eqmg==} + engines: {node: '>=18'} + '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -1064,6 +1084,9 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@microsoft/tsdoc@0.17.1': + resolution: {integrity: sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1348,6 +1371,18 @@ packages: '@stryker-mutator/core': 10.0.0 vitest: '>=2.0.0' + '@stylistic/eslint-plugin@6.0.0-beta.6': + resolution: {integrity: sha512-DqDQZSrV/0AZ/4qLxaQUtNxafk3qWfeG5xOHx64ELjHWSHt2GtELZR6ClrNVvQwo/d13Atf8Hrh/s/ZnuJk57w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + '@turbo/darwin-64@2.11.5': resolution: {integrity: sha512-KdY4y7BBjIisl+jAkb3FFaRjClGqHyaeHCcVcCT8LBzFFwUu6thgfxyRZAx0uL4KoxLmH+iOan8UhVf/aDDcaA==} cpu: [x64] @@ -1432,6 +1467,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.1': resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1440,6 +1481,10 @@ packages: resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.1': resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1452,6 +1497,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.69.0': resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1467,6 +1518,10 @@ packages: resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.1': resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1479,6 +1534,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.1': resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1493,6 +1554,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.1': resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1501,6 +1569,10 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -2109,6 +2181,13 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-json-canonical@2.1.3: + resolution: {integrity: sha512-qAcRVGkXGZvgduOIEBxHLOZhqUf0NdVY+bye/KXbd0oPH5VvoBBBETFEaJ1qa6efLSCH17C4sss7uvr80TfNTQ==} + engines: {node: '>=20'} + peerDependencies: + '@eslint/json': ^2.1.0 + eslint: '>=10.0.0' + eslint-plugin-prettier@5.5.6: resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3741,6 +3820,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -4350,6 +4434,13 @@ snapshots: optionalDependencies: eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + '@eslint/json@2.1.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanwhocodes/momoa': 3.3.13 + natural-compare: 1.4.0 + '@eslint/object-schema@3.0.5': {} '@eslint/plugin-kit@0.7.3': @@ -4357,17 +4448,26 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exadev/eslint-config@2.12.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3)': + '@exadev/eslint-config@3.0.0(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript-eslint@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) - '@typescript-eslint/utils': 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@microsoft/tsdoc': 0.17.1 + '@stylistic/eslint-plugin': 6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint-plugin-json-canonical: 2.1.3(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) eslint-plugin-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + minimatch: 10.2.6 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 typescript-eslint: 8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + yaml: 2.9.1 + optionalDependencies: + '@eslint/json': 2.1.1 transitivePeerDependencies: + - oxlint - supports-color '@humanfs/core@0.19.2': @@ -4384,6 +4484,8 @@ snapshots: '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/momoa@3.3.13': {} + '@humanwhocodes/retry@0.4.3': {} '@inquirer/ansi@2.0.8': {} @@ -4540,6 +4642,8 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@microsoft/tsdoc@0.17.1': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.8': @@ -4873,6 +4977,16 @@ snapshots: tslib: 2.8.1 vitest: 4.1.11(@types/node@26.4.1)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@stylistic/eslint-plugin@6.0.0-beta.6(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + espree: 11.2.0 + picomatch: 4.0.7 + optionalDependencies: + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + '@turbo/darwin-64@2.11.5': optional: true @@ -4960,6 +5074,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -4970,6 +5093,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 '@typescript-eslint/visitor-keys': 8.69.0 + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -4978,6 +5106,10 @@ snapshots: dependencies: typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.69.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.69.0 @@ -4994,6 +5126,8 @@ snapshots: '@typescript-eslint/types@8.69.0': {} + '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/typescript-estree@8.56.1(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.56.1(supports-color@7.2.0)(typescript@6.0.3) @@ -5024,6 +5158,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) @@ -5046,6 +5195,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.71.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.56.1': dependencies: '@typescript-eslint/types': 8.56.1 @@ -5056,6 +5216,11 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5613,6 +5778,11 @@ snapshots: - supports-color - typescript + eslint-plugin-json-canonical@2.1.3(@eslint/json@2.1.1)(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + '@eslint/json': 2.1.1 + eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.6): dependencies: eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0) @@ -7061,6 +7231,8 @@ snapshots: yaml@2.9.0: optional: true + yaml@2.9.1: {} + yargs-parser@20.2.9: {} yargs-parser@22.0.0: {} diff --git a/scripts/build-sea.ts b/scripts/build-sea.ts index 0888550..24b4b53 100644 --- a/scripts/build-sea.ts +++ b/scripts/build-sea.ts @@ -1,6 +1,6 @@ -// Builds a Node single-executable application (SEA) binary from this repository's tsdown-bundled dist-sea/sea-entry.cjs (see tsdown.config.ts's seaEntryBuildConfig), for whichever platform this script runs on — one call per platform cell of the CI matrix, never cross-compiled: node --build-sea copies the CURRENT running node binary, so the platform this script runs under is the platform the resulting binary targets. Uses the single `node --build-sea` flag (Node 25.5.0+, https://nodejs.org/api/single-executable-applications.html), not the older two-step --experimental-sea-config-then-postject workflow that flag replaced: --build-sea generates the preparation blob and injects it into a copy of the running binary in one internal step, writing the finished executable straight to sea-config.json's own "output" path. -// -// Pattern follows ExaDev/documents.js's .github/scripts/build-sea-binary.ts, which proved each of the non-obvious steps below in real CI runs. +/* Builds a Node single-executable application (SEA) binary from this repository's tsdown-bundled dist-sea/sea-entry.cjs (see tsdown.config.ts's seaEntryBuildConfig), for whichever platform this script runs on — one call per platform cell of the CI matrix, never cross-compiled: node --build-sea copies the CURRENT running node binary, so the platform this script runs under is the platform the resulting binary targets. Uses the single `node --build-sea` flag (Node 25.5.0+, https://nodejs.org/api/single-executable-applications.html), not the older two-step --experimental-sea-config-then-postject workflow that flag replaced: --build-sea generates the preparation blob and injects it into a copy of the running binary in one internal step, writing the finished executable straight to sea-config.json's own "output" path. + + Pattern follows ExaDev/documents.js's .github/scripts/build-sea-binary.ts, which proved each of the non-obvious steps below in real CI runs. */ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -29,6 +29,7 @@ function binaryFileName( arch: SeaArch, ): string { const suffixed = `${binaryName}-${PLATFORM_LABELS[platform]}-${arch}`; + return platform === "win32" ? `${suffixed}.exe` : suffixed; } @@ -40,6 +41,7 @@ function postBuildCommandsFor( if (platform === "darwin") { return [["codesign", ["--sign", "-", "--force", binaryPath]]]; } + return []; } @@ -57,6 +59,7 @@ function assertBundle(): string { `Expected ${BUNDLE_PATH} to exist — run this repository's own build (which produces dist-sea/) first.`, ); } + return BUNDLE_PATH; } diff --git a/src/adapters/node/adapters-extra.integration.test.ts b/src/adapters/node/adapters-extra.integration.test.ts index ef6deaf..d29118e 100644 --- a/src/adapters/node/adapters-extra.integration.test.ts +++ b/src/adapters/node/adapters-extra.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { spawn } from "node:child_process"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -48,13 +48,12 @@ describe.skipIf(process.platform === "win32")("PsProcInfo", () => { }); test("a missing ps binary resolves lstart to undefined", async () => { - const realPath = process.env.PATH; - process.env.PATH = "/nonexistent-cc-peer-test-bin"; + vi.stubEnv("PATH", "/nonexistent-cc-peer-test-bin"); try { const fresh = new PsProcInfo(); expect(await fresh.lstart(process.pid)).toBeUndefined(); } finally { - if (realPath !== undefined) process.env.PATH = realPath; + vi.unstubAllEnvs(); } }); }); @@ -81,7 +80,7 @@ describe("UdsTransport connection lifecycle", () => { const home = await tempHome(); const sockPath = testSocketPath(home, "lifecycle"); const transport = new UdsTransport(); - let received: string[] = []; + const received: string[] = []; let iterationEnded = false; const listener = await transport.listen(sockPath, (conn) => { expect(conn.peerPid()).toBeUndefined(); @@ -105,7 +104,6 @@ describe("UdsTransport connection lifecycle", () => { expect(received).toEqual(['{"type":"auth"}', '{"type":"user"}']); expect(iterationEnded).toBe(true); await listener.close(); - received = []; }); test("closing the listener destroys an accepted open connection", async () => { diff --git a/src/adapters/node/alias-worker.ts b/src/adapters/node/alias-worker.ts index 0c3025a..6ba6974 100644 --- a/src/adapters/node/alias-worker.ts +++ b/src/adapters/node/alias-worker.ts @@ -35,10 +35,12 @@ async function handleCommand(raw: unknown): Promise { if (!AliasCommandSchema.is(raw)) return; if (raw.type === "stop") { await shutdown(); + return; } if (raw.type === "send") { await handleSend(raw); + return; } let created: CcPeer; @@ -51,6 +53,7 @@ async function handleCommand(raw: unknown): Promise { }); } catch { process.exit(1); + return; } peer = created; @@ -69,6 +72,7 @@ async function handleSend(command: Readonly): Promise { code: "NOT_STARTED", message: "alias peer has not started", }); + return; } try { diff --git a/src/adapters/node/cached-command.ts b/src/adapters/node/cached-command.ts index 0987642..2b635f9 100644 --- a/src/adapters/node/cached-command.ts +++ b/src/adapters/node/cached-command.ts @@ -11,6 +11,7 @@ export function errnoOf(error: unknown): string { ) { return error.code; } + return ""; } @@ -32,7 +33,9 @@ export class CachedPidCommand { number, { at: number; value: string | undefined } >(); + private static readonly CACHE_MS = 60_000; + private readonly inFlight = new Map>(); async run( @@ -70,6 +73,7 @@ export class CachedPidCommand { this.inFlight.delete(pid); }); this.inFlight.set(pid, promise); + return promise; } } diff --git a/src/adapters/node/forked-alias-process.integration.test.ts b/src/adapters/node/forked-alias-process.integration.test.ts index c4ce9d2..2a29101 100644 --- a/src/adapters/node/forked-alias-process.integration.test.ts +++ b/src/adapters/node/forked-alias-process.integration.test.ts @@ -72,6 +72,7 @@ async function readAliasKey( throw new Error(`no key file found for socket ${socketPath}`); } const raw = await readFile(join(sessionsDir, match), "utf8"); + return JSON.parse(raw) as PeerKeyFile; } diff --git a/src/adapters/node/forked-alias-process.ts b/src/adapters/node/forked-alias-process.ts index 2dc1b4a..1edeba9 100644 --- a/src/adapters/node/forked-alias-process.ts +++ b/src/adapters/node/forked-alias-process.ts @@ -35,9 +35,13 @@ export interface ForkedAliasProcessDeps { */ export class ForkedAliasProcess implements AliasProcess { readonly events = new EventEmitter(); + private readonly forkFn: typeof fork; + private readonly workerPath: string; + private readonly pendingSends = new Map(); + private child: ChildProcess | undefined; constructor(deps: Readonly) { @@ -51,6 +55,7 @@ export class ForkedAliasProcess implements AliasProcess { child.on("message", (raw: unknown) => { if (AliasMessageEventSchema.is(raw)) { this.events.emit("message", toInboundMessage(raw)); + return; } this.settleSend(raw); @@ -102,6 +107,7 @@ export class ForkedAliasProcess implements AliasProcess { throw new AliasSendError("alias worker is not running"); } const requestId = newMsgId(); + return new Promise<{ msgId: string }>((resolve, reject) => { this.pendingSends.set(requestId, { resolve, reject }); child.send({ type: "send", requestId, target, body }); @@ -112,6 +118,7 @@ export class ForkedAliasProcess implements AliasProcess { private settleSend(raw: unknown): void { if (AliasSentEventSchema.is(raw)) { this.takePendingSend(raw.requestId)?.resolve({ msgId: raw.msgId }); + return; } if (AliasSendFailedEventSchema.is(raw)) { @@ -124,6 +131,7 @@ export class ForkedAliasProcess implements AliasProcess { private takePendingSend(requestId: string): PendingSend | undefined { const pending = this.pendingSends.get(requestId); this.pendingSends.delete(requestId); + return pending; } @@ -140,6 +148,7 @@ export class ForkedAliasProcess implements AliasProcess { const child = this.child; if (child === undefined) return undefined; if (child.exitCode !== null || child.signalCode !== null) return undefined; + return child; } diff --git a/src/adapters/node/forked-alias-process.unit.test.ts b/src/adapters/node/forked-alias-process.unit.test.ts index be9a4f6..9edaf53 100644 --- a/src/adapters/node/forked-alias-process.unit.test.ts +++ b/src/adapters/node/forked-alias-process.unit.test.ts @@ -9,11 +9,14 @@ import { AliasSendCommandSchema } from "../../schemas/alias-ipc.js"; /** A fake ChildProcess: just enough of the EventEmitter + send() surface for the adapter's own protocol logic, driven manually by each test. */ class FakeChild extends EventEmitter { sent: unknown[] = []; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; send(message: unknown): boolean { this.sent.push(message); + return true; } @@ -30,8 +33,10 @@ function makeForkedAliasProcess(child: FakeChild): { const forkCalls: unknown[][] = []; const fork = vi.fn((modulePath: string, args: unknown, options: unknown) => { forkCalls.push([modulePath, args, options]); + return child as unknown as ChildProcess; }); + return { process: new ForkedAliasProcess({ fork: fork as never, @@ -203,6 +208,7 @@ function lastSendRequestId(child: FakeChild): string { if (!AliasSendCommandSchema.is(command)) { throw new Error("the last IPC command was not a send command"); } + return command.requestId; } @@ -215,6 +221,7 @@ async function startedProcess(): Promise<{ const pending = proc.start({ name: "alice" }); child.emit("message", { type: "started" }); await pending; + return { child, proc }; } diff --git a/src/adapters/node/fs-key-store.ts b/src/adapters/node/fs-key-store.ts index aeb6532..07b79dc 100644 --- a/src/adapters/node/fs-key-store.ts +++ b/src/adapters/node/fs-key-store.ts @@ -23,6 +23,7 @@ export class FsKeyStore implements KeyStore { return undefined; } const result = PeerKeyFileSchema.safeParse(parsed); + return result.success ? result.data : undefined; } diff --git a/src/adapters/node/fs-registry-store.ts b/src/adapters/node/fs-registry-store.ts index 1c55c3a..ab258e0 100644 --- a/src/adapters/node/fs-registry-store.ts +++ b/src/adapters/node/fs-registry-store.ts @@ -35,6 +35,7 @@ export class FsRegistryStore implements RegistryStore { const entry = await this.read(Number.parseInt(name, 10)); if (entry !== undefined) entries.push(entry); } + return entries; } @@ -52,6 +53,7 @@ export class FsRegistryStore implements RegistryStore { return undefined; } const result = RegistryEntrySchema.safeParse(parsed); + return result.success ? result.data : undefined; } diff --git a/src/adapters/node/paths.ts b/src/adapters/node/paths.ts index bc29fe1..0faff7c 100644 --- a/src/adapters/node/paths.ts +++ b/src/adapters/node/paths.ts @@ -25,9 +25,10 @@ export function socketDirCandidates( config: Readonly = {}, ): [string, ...string[]] { if (config.socketDir !== undefined) return [config.socketDir]; - // /tmp/cc-socks and /private/tmp/cc-socks (its realpath on macOS), plus the - // XDG runtime and Termux variants the reference client also accepts. + /* /tmp/cc-socks and /private/tmp/cc-socks (its realpath on macOS), plus the + XDG runtime and Termux variants the reference client also accepts. */ const runtimeDir = process.env.XDG_RUNTIME_DIR; + return [ "/tmp/cc-socks", "/private/tmp/cc-socks", @@ -64,12 +65,14 @@ export function socketPathForPid( config.socketDir === undefined ? "" : `-${pipeNamespace(config.socketDir)}`; + return `${WINDOWS_PIPE_PREFIX}cc-peer${namespace}-${pid.toString()}`; } // An explicit socketDir always wins on POSIX, matching socketDirCandidates' own precedence rule: it is a full override of automatic path construction, not merely a candidate to prefer. if (config.socketDir !== undefined) { return `${config.socketDir}/${pid.toString()}.sock`; } + return `${socketDirCandidates(config)[0]}/${pid.toString()}.sock`; } @@ -86,6 +89,7 @@ export function keyFilePath( config: Readonly = {}, ): string { const hash = createHash("sha256").update(socketPath).digest("hex"); + return join( sessionsDir(config), `${pidFromSocketPath(socketPath).toString()}.${hash}.key`, @@ -97,11 +101,13 @@ export function pidFromSocketPath(socketPath: string): number { if (socketPath.startsWith(WINDOWS_PIPE_PREFIX)) { const name = socketPath.slice(WINDOWS_PIPE_PREFIX.length); const pid = Number.parseInt(name.slice(name.lastIndexOf("-") + 1), 10); + return Number.isNaN(pid) ? 0 : pid; } // substring after the final slash: split().at(-1) would need an unreachable empty-array fallback. const base = socketPath.substring(socketPath.lastIndexOf("/") + 1); const pid = Number.parseInt(base.replace(/\.sock$/, ""), 10); + return Number.isNaN(pid) ? 0 : pid; } diff --git a/src/adapters/node/ps-proc-info.ts b/src/adapters/node/ps-proc-info.ts index 61544ff..3f3b157 100644 --- a/src/adapters/node/ps-proc-info.ts +++ b/src/adapters/node/ps-proc-info.ts @@ -18,6 +18,7 @@ export class PsProcInfo implements ProcInfo { "-p", pid.toString(), ]); + return stdout?.trim(); } } diff --git a/src/adapters/node/uds-transport.ts b/src/adapters/node/uds-transport.ts index ef1a5a6..a144915 100644 --- a/src/adapters/node/uds-transport.ts +++ b/src/adapters/node/uds-transport.ts @@ -11,9 +11,13 @@ const DEFAULT_LINGER_MS = 150; class NodeInboundConnection implements InboundConnection { private buffer = ""; + private readonly lines: string[] = []; + private readonly waiters: ((line: string | undefined) => void)[] = []; + private ended = false; + /** macOS local-peer pid, captured from the first data chunk's control info. */ private cachedPid: number | undefined; @@ -137,6 +141,7 @@ export class UdsTransport implements Transport { resolve(); }); }); + return { socketPath, close: async () => { diff --git a/src/adapters/node/win-proc-info.ts b/src/adapters/node/win-proc-info.ts index c35a5b2..276d1e8 100644 --- a/src/adapters/node/win-proc-info.ts +++ b/src/adapters/node/win-proc-info.ts @@ -19,6 +19,7 @@ export class WinProcInfo implements ProcInfo { "-Command", script, ]); + return stdout?.trim(); } } diff --git a/src/alias-pool.ts b/src/alias-pool.ts index 8c50446..6063732 100644 --- a/src/alias-pool.ts +++ b/src/alias-pool.ts @@ -44,7 +44,9 @@ interface Deps { */ export class AliasPool extends EventEmitter { private readonly active = new Map(); + private readonly pending = new Map>(); + private readonly log: (message: string) => void; constructor( @@ -79,6 +81,7 @@ export class AliasPool extends EventEmitter { body: string, ): Promise<{ msgId: string }> { const proc = await this.acquire(name); + return proc.send(target, body); } @@ -117,6 +120,7 @@ export class AliasPool extends EventEmitter { }); this.active.set(name, proc); this.log(`alias ${name} active`); + return proc; } diff --git a/src/alias-pool.unit.test.ts b/src/alias-pool.unit.test.ts index 7394295..20f9ce5 100644 --- a/src/alias-pool.unit.test.ts +++ b/src/alias-pool.unit.test.ts @@ -23,17 +23,25 @@ describe("workerExtensionFor", () => { /** A fake AliasProcess whose start()/stop() are externally controllable, so tests can assert ordering and concurrency without a real child process. */ class FakeAliasProcess implements AliasProcess { readonly events = new EventEmitter(); + startCalls: unknown[] = []; + sendCalls: { target: Readonly; body: string }[] = []; + stopCalls = 0; + private resolveStart: (() => void) | undefined; + private rejectStart: ((error: Error) => void) | undefined; + private resolveSend: ((result: Readonly<{ msgId: string }>) => void) | undefined; + private rejectSend: ((error: Error) => void) | undefined; async start(options: unknown): Promise { this.startCalls.push(options); + return new Promise((resolve, reject) => { this.resolveStart = resolve; this.rejectStart = reject; @@ -45,6 +53,7 @@ class FakeAliasProcess implements AliasProcess { body: string, ): Promise<{ msgId: string }> { this.sendCalls.push({ target, body }); + return new Promise<{ msgId: string }>((resolve, reject) => { this.resolveSend = resolve; this.rejectSend = reject; @@ -53,6 +62,7 @@ class FakeAliasProcess implements AliasProcess { async stop(): Promise { this.stopCalls += 1; + return Promise.resolve(); } @@ -84,8 +94,10 @@ function makePool(procs: readonly FakeAliasProcess[]): { if (proc === undefined) { throw new Error("makePool: not enough fake processes provided"); } + return proc; }); + return { pool: new AliasPool({ spawn }), spawnCount: () => spawn.mock.calls.length, @@ -108,6 +120,7 @@ describe("AliasPool.ensure", () => { let index = 0; const spawn = vi.fn(() => { index += 1; + return proc; }); const pool = new AliasPool( diff --git a/src/api/schemas.ts b/src/api/schemas.ts index bf8bcc2..7bfbe5e 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -136,6 +136,7 @@ export function componentSchemasFrom( delete rest.$schema; stripped[name] = rest; } + return stripped; } diff --git a/src/api/server.ts b/src/api/server.ts index 7c1744e..e0c8de0 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -48,6 +48,7 @@ export async function createApiServer( const server = createServer((req, res) => { void handle(peer, req, res, token); }); + return new Promise((resolve) => { server.listen(options.port ?? 0, BIND_HOST, () => { const port = listeningPort(server.address()); @@ -84,12 +85,14 @@ async function handle( const host = hostnameOf(req.headers.host); if (!ALLOWED_HOSTS.has(host)) { finish(HTTP_FORBIDDEN, errorBody("host not allowed")); + return; } if (token !== undefined) { const auth = req.headers.authorization ?? ""; if (auth !== `Bearer ${token}`) { finish(HTTP_UNAUTHORIZED, errorBody("unauthorized")); + return; } } @@ -97,15 +100,18 @@ async function handle( try { if (req.method === "GET" && url.pathname === "/healthz") { finish(HTTP_OK, JSON.stringify({ ok: true })); + return; } if (req.method === "GET" && url.pathname === "/openapi.json") { finish(HTTP_OK, JSON.stringify(openApiDocument())); + return; } if (req.method === "GET" && url.pathname === "/sessions") { const roster = await peer.roster(); finish(HTTP_OK, JSON.stringify({ sessions: roster })); + return; } if (req.method === "POST" && url.pathname === "/messages") { @@ -121,6 +127,7 @@ async function handle( : {}), }); finish(HTTP_ACCEPTED, JSON.stringify(sent)); + return; } if (req.method === "POST" && url.pathname === "/idle-subscriptions") { @@ -129,10 +136,12 @@ async function handle( ); const sent = await peer.subscribeIdle(toPeerRef(request.to)); finish(HTTP_ACCEPTED, JSON.stringify(sent)); + return; } if (req.method === "GET" && url.pathname === "/events") { streamEvents(peer, req, res); + return; } finish(HTTP_NOT_FOUND, errorBody("not found")); @@ -150,6 +159,7 @@ export function listeningPort(address: string | AddressInfo | null): number { "expected the server to report an AddressInfo after listen()", ); } + return address.port; } @@ -159,6 +169,7 @@ export function listeningPort(address: string | AddressInfo | null): number { export function hostnameOf(hostHeader: string | undefined): string { if (hostHeader === undefined) return ""; const colonIndex = hostHeader.indexOf(":"); + return colonIndex === -1 ? hostHeader : hostHeader.slice(0, colonIndex); } @@ -257,6 +268,7 @@ function openApiDocument(): Record { required: true, content: { "application/json": { schema: ref(name) } }, }); + return { openapi: "3.1.0", info: { diff --git a/src/bin/main.ts b/src/bin/main.ts index 2e14354..75a573b 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -78,6 +78,7 @@ export function parseArgs(argv: readonly string[]): CliCommand { args.home = value; } } + return { kind: "run", args }; } @@ -91,6 +92,7 @@ export async function main( const command = parseArgs(argv); if (command.kind === "help") { process.stdout.write(USAGE); + return; } if (command.kind === "error") { @@ -98,6 +100,7 @@ export async function main( `[cc-peer] ${command.message}\nRun 'cc-peer --help' for usage.\n`, ); process.exitCode = 2; + return; } const { args } = command; diff --git a/src/cc-peer-class.integration.test.ts b/src/cc-peer-class.integration.test.ts index 4d62716..a34a9b1 100644 --- a/src/cc-peer-class.integration.test.ts +++ b/src/cc-peer-class.integration.test.ts @@ -29,12 +29,17 @@ async function tempHome(): Promise { return mkdtemp(join(tmpdir(), "cc-peer-class-")); } -function peerOptions(home: string, name?: string, sessionId?: string) { +function peerOptions( + home: string, + identity: Readonly<{ name?: string; sessionId?: string }> = {}, +) { return { homeDir: home, socketDir: join(home, "socks"), - ...(name !== undefined ? { name } : {}), - ...(sessionId !== undefined ? { sessionId } : {}), + ...(identity.name !== undefined ? { name: identity.name } : {}), + ...(identity.sessionId !== undefined + ? { sessionId: identity.sessionId } + : {}), }; } @@ -50,7 +55,7 @@ function makePeer( ): CcPeer { return new CcPeer( { - ...peerOptions(home, overrides.name, overrides.sessionId), + ...peerOptions(home, overrides), ...(overrides.heartbeatMs !== undefined ? { heartbeatMs: overrides.heartbeatMs } : {}), @@ -80,6 +85,7 @@ async function rawClient(peer: CcPeer, socketPath?: string): Promise { socketPath ?? socketPathForPid(process.pid, peerOptions(tempHomeOf(peer))); const socket = connect(path); await once(socket, "connect"); + return socket; } @@ -137,6 +143,7 @@ describe("CcPeer dependency-injected construction", () => { transport: { listen: async (path) => { listenedPath = path; + return Promise.resolve({ socketPath: path, close: async () => Promise.resolve(), @@ -397,6 +404,7 @@ async function keyedTarget(home: string): Promise { pidDomain: "darwin", }); targetsToClose.push(listener); + return targetPath; } @@ -406,8 +414,8 @@ describe("CcPeer send happy paths by pid and address", () => { const peer = makePeer(home); await peer.start(); const keys = new FsKeyStore({ homeDir: home }); - // A target pid distinct from our own so the resolved socket path does - // not collide with the sender's own listening socket. + /* A target pid distinct from our own so the resolved socket path does + not collide with the sender's own listening socket. */ const TARGET_PID = 88001; const pidPath = socketPathForPid(TARGET_PID, { socketDir: join(home, "socks"), @@ -475,9 +483,9 @@ describe("CcPeer send happy paths by pid and address", () => { receiver.on("message", (m) => { messages.push(m); }); - // Both peers share one pid, so the single registry file holds whichever - // peer started last: the receiver, which is exactly what the sender's - // name resolution needs to find. + /* Both peers share one pid, so the single registry file holds whichever + peer started last: the receiver, which is exactly what the sender's + name resolution needs to find. */ await sender.send({ name: "opt-receiver" }, "attested default"); await sender.send({ name: "opt-receiver" }, "prompting", { fromMode: "prompting", @@ -511,9 +519,9 @@ describe("CcPeer send happy paths by pid and address", () => { await peer.start(); const target = await keyedTarget(home); const started = Date.now(); - // Concurrent sends exhaust the bucket faster than the 0.5/s refill can - // top it up (serial sends each pay connectWrite's 150ms linger, letting - // the refill mask the exhaustion). + /* Concurrent sends exhaust the bucket faster than the 0.5/s refill can + top it up (serial sends each pay connectWrite's 150ms linger, letting + the refill mask the exhaustion). */ await Promise.all( Array.from({ length: PACER_CAPACITY + 1 }, async (_, i) => peer.send({ address: target }, "burst " + i.toString()), diff --git a/src/cc-peer.ts b/src/cc-peer.ts index 5a2a320..9bd1b7e 100644 --- a/src/cc-peer.ts +++ b/src/cc-peer.ts @@ -99,10 +99,15 @@ const DEFAULT_HEARTBEAT_MS = 15_000; */ export class CcPeer extends EventEmitter { private listening: ListeningSocket | undefined; + private heartbeatTimer: NodeJS.Timeout | undefined; + private ownKey: PeerKeyFile | undefined; + private readonly pacer: Pacer; + private readonly log: (message: string) => void; + private stopped = false; constructor( @@ -123,6 +128,7 @@ export class CcPeer extends EventEmitter { clock: new SystemClock(), }); await peer.start(); + return peer; } @@ -160,6 +166,7 @@ export class CcPeer extends EventEmitter { /** procStart is a required parameter, not read from this.ownKey: start() already guarantees a non-empty value before this is called, so the type checker enforces it rather than a runtime fallback that can never actually fire. */ private buildRegistryEntry(procStart: string): RegistryEntry { const now = this.deps.clock.nowMs(); + return { pid: process.pid, sessionId: this.options.sessionId ?? newMsgId(), @@ -189,6 +196,7 @@ export class CcPeer extends EventEmitter { async roster(): Promise { const entries = await this.deps.registry.list(); + return filterRoster(entries, { transport: this.deps.transport, procInfo: this.deps.procInfo, @@ -251,6 +259,7 @@ export class CcPeer extends EventEmitter { ); } await this.pacedSend(socketPath, [JSON.stringify(auth), line]); + return { msgId }; } @@ -299,6 +308,7 @@ export class CcPeer extends EventEmitter { JSON.stringify(auth), JSON.stringify(frame), ]); + return { msgId }; } @@ -312,6 +322,7 @@ export class CcPeer extends EventEmitter { if (match === undefined) { throw new UnknownPeerError(`no roster entry named ${target.name}`); } + return match.messagingSocketPath; } @@ -334,6 +345,7 @@ export class CcPeer extends EventEmitter { if (!authenticated) { this.log("inbound auth line missing or mismatched (connection closed)"); conn.close(); + return; } } else if (!authenticated) { diff --git a/src/domain/dedup.ts b/src/domain/dedup.ts index fa886a0..b3dff35 100644 --- a/src/domain/dedup.ts +++ b/src/domain/dedup.ts @@ -8,5 +8,6 @@ export const DEDUP_WINDOW_MS = 30_000; */ export function varyBody(body: string, nth: number): string { const zwj = "‍"; + return body + zwj.repeat(nth + 1); } diff --git a/src/domain/envelope.ts b/src/domain/envelope.ts index 3d7bcf1..8373300 100644 --- a/src/domain/envelope.ts +++ b/src/domain/envelope.ts @@ -45,11 +45,13 @@ function serializeAttributes(attrs: EnvelopeAttributes): string { if (attrs.fromMode !== undefined) { parts.push(` from-mode="${attrs.fromMode}"`); } + return parts.join(""); } export function buildEnvelope(attrs: EnvelopeAttributes, body: string): string { const validated = EnvelopeAttributesSchema.parse(attrs); + return `<${TAG}${serializeAttributes(validated)}>\n${escapeBody(body)}\n`; } @@ -66,8 +68,8 @@ export interface ParsedEnvelope { export function parseEnvelope(content: string): ParsedEnvelope | undefined { const match = ENVELOPE_RE.exec(content); if (match === null) return undefined; - // The body is derived positionally from the validated content rather than read from a capture group: attribute grammars bar ">" and newlines, so the first ">\n" is the opening tag's end, and the regex anchor guarantees the "\n" suffix. This avoids an index access that - // noUncheckedIndexedAccess would type string | undefined. + /* The body is derived positionally from the validated content rather than read from a capture group: attribute grammars bar ">" and newlines, so the first ">\n" is the opening tag's end, and the regex anchor guarantees the "\n" suffix. This avoids an index access that + noUncheckedIndexedAccess would type string | undefined. */ const openingEnd = content.indexOf(">\n") + 2; const closingStart = content.length - `\n`.length; const parsed: ParsedEnvelope = { @@ -80,6 +82,7 @@ export function parseEnvelope(content: string): ParsedEnvelope | undefined { if (match[5] === "bypass" || match[5] === "prompting") { parsed.fromMode = match[5]; } + return parsed; } @@ -101,5 +104,6 @@ export function assertRoundTrips(content: string): boolean { }, parsed.body, ); + return rebuilt === content; } diff --git a/src/domain/file-transfer.ts b/src/domain/file-transfer.ts index fd64c0f..f1406ba 100644 --- a/src/domain/file-transfer.ts +++ b/src/domain/file-transfer.ts @@ -54,6 +54,7 @@ export async function stageFile( `${sha256.slice(0, ID_PREFIX_CHARS)}-${randomUUID().slice(0, ID_PREFIX_CHARS)}-${safeName(basename(sourcePath))}`, ); await writeFile(staged, bytes, { mode: 0o600 }); + return { path: staged, file_name: basename(sourcePath), @@ -106,6 +107,7 @@ export async function materialiseAttachment( await mkdir(dirname(dest), { recursive: true, mode: 0o700 }); await copyFile(absolute, dest); await unlink(absolute); + return { uploadPath: dest, mention: `@"${dest}"` }; } @@ -118,6 +120,7 @@ export function capAttachments(attachments: readonly FileAttachment[]): { return { kept: [...attachments], droppedNote: undefined }; } const dropped = attachments.length - MAX_ATTACHMENTS_PER_MESSAGE; + return { kept: attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE), droppedNote: `[SendFile: ${dropped.toString()} additional attachment(s) were dropped — max ${MAX_ATTACHMENTS_PER_MESSAGE.toString()} per message]`, diff --git a/src/domain/hop-chain.ts b/src/domain/hop-chain.ts index 23cc6f3..ceca075 100644 --- a/src/domain/hop-chain.ts +++ b/src/domain/hop-chain.ts @@ -13,6 +13,7 @@ export function appendHop( ownId: string, ): string[] { const next = [...(chain ?? []), ownId]; + return next.slice(Math.max(0, next.length - MAX_HOP_CHAIN_ENTRIES)); } @@ -37,5 +38,6 @@ export function checkChain( if (selfHops >= MAX_SELF_HOPS) { return { admitted: false, reason: "hop-loop" }; } + return { admitted: true }; } diff --git a/src/domain/pacer.ts b/src/domain/pacer.ts index 73dbe33..16e4933 100644 --- a/src/domain/pacer.ts +++ b/src/domain/pacer.ts @@ -6,8 +6,8 @@ export const DEFAULT_REFILL_PER_SECOND = 0.5; const MS_PER_SECOND = 1_000; export interface PacerOptions { - capacity?: number; - refillPerSecond?: number; + readonly capacity?: number; + readonly refillPerSecond?: number; } /** @@ -15,14 +15,20 @@ export interface PacerOptions { */ export class Pacer { private tokens: number; + private lastRefillMs: number; + private readonly capacity: number; + + private readonly refillPerSecond: number; + constructor( private readonly clock: Readonly, - private readonly capacity: number = DEFAULT_BUCKET_CAPACITY, - private readonly refillPerSecond: number = DEFAULT_REFILL_PER_SECOND, + options: PacerOptions = {}, ) { - this.tokens = capacity; + this.capacity = options.capacity ?? DEFAULT_BUCKET_CAPACITY; + this.refillPerSecond = options.refillPerSecond ?? DEFAULT_REFILL_PER_SECOND; + this.tokens = this.capacity; this.lastRefillMs = clock.nowMs(); } @@ -30,6 +36,7 @@ export class Pacer { msUntilNextToken(): number { this.refill(); const deficit = Math.max(0, 1 - this.tokens); + return Math.ceil((deficit / this.refillPerSecond) * MS_PER_SECOND); } @@ -38,6 +45,7 @@ export class Pacer { this.refill(); if (this.tokens < 1) return false; this.tokens -= 1; + return true; } diff --git a/src/domain/roster.ts b/src/domain/roster.ts index 87786da..1bce97b 100644 --- a/src/domain/roster.ts +++ b/src/domain/roster.ts @@ -29,6 +29,7 @@ export async function filterRoster( const verdicts = await Promise.all( entries.map(async (entry) => checkEntry(entry, probes)), ); + return verdicts.filter((v) => v.admitted).map((v) => v.entry); } @@ -57,5 +58,6 @@ export async function checkEntry( if (!connectable) { return { entry, admitted: false, reason: "socket-dead" }; } + return { entry, admitted: true }; } diff --git a/src/domain/units.unit.test.ts b/src/domain/units.unit.test.ts index a40621e..b0702c5 100644 --- a/src/domain/units.unit.test.ts +++ b/src/domain/units.unit.test.ts @@ -11,9 +11,11 @@ const NO_START = "no-start"; class FakeClock { constructor(private now: number) {} + nowMs(): number { return this.now; } + advance(ms: number): void { this.now += ms; } @@ -22,7 +24,7 @@ class FakeClock { describe("Pacer", () => { test("refills tokens over elapsed time and blocks until one is available", () => { const clock = new FakeClock(1_000_000); - const pacer = new Pacer(clock, 2, 0.5); + const pacer = new Pacer(clock, { capacity: 2, refillPerSecond: 0.5 }); expect(pacer.tryReserve()).toBe(true); expect(pacer.tryReserve()).toBe(true); expect(pacer.tryReserve()).toBe(false); @@ -40,20 +42,23 @@ describe("Pacer", () => { test("capacity caps the refill", () => { const clock = new FakeClock(0); - const pacer = new Pacer(clock, 1, 1); + const pacer = new Pacer(clock, { capacity: 1, refillPerSecond: 1 }); clock.advance(60_000); expect(pacer.tryReserve()).toBe(true); expect(pacer.tryReserve()).toBe(false); }); test("msUntilNextToken is zero while tokens remain", () => { - const pacer = new Pacer(new FakeClock(0), 3, 0.5); + const pacer = new Pacer(new FakeClock(0), { + capacity: 3, + refillPerSecond: 0.5, + }); expect(pacer.msUntilNextToken()).toBe(0); }); test("msUntilNextToken refills from elapsed time on its own, without a prior tryReserve", () => { const clock = new FakeClock(0); - const pacer = new Pacer(clock, 1, 1); + const pacer = new Pacer(clock, { capacity: 1, refillPerSecond: 1 }); expect(pacer.tryReserve()).toBe(true); clock.advance(1_000); expect(pacer.msUntilNextToken()).toBe(0); @@ -61,7 +66,7 @@ describe("Pacer", () => { test("msUntilNextToken reports the exact wait for a fractional deficit", () => { const clock = new FakeClock(0); - const pacer = new Pacer(clock, 1, 1); + const pacer = new Pacer(clock, { capacity: 1, refillPerSecond: 1 }); expect(pacer.tryReserve()).toBe(true); clock.advance(500); expect(pacer.msUntilNextToken()).toBe(500); @@ -69,7 +74,7 @@ describe("Pacer", () => { test("refill ignores a clock that moves backward rather than draining tokens", () => { const clock = new FakeClock(10_000); - const pacer = new Pacer(clock, 1, 1); + const pacer = new Pacer(clock, { capacity: 1, refillPerSecond: 1 }); expect(pacer.tryReserve()).toBe(true); clock.advance(-5_000); expect(pacer.msUntilNextToken()).toBe(1_000); @@ -116,6 +121,7 @@ function probes( Promise.resolve(overrides.alive ?? true); const probe = async (): Promise => Promise.resolve(overrides.probe ?? true); + return { transport: { probe }, procInfo: { alive, lstart }, diff --git a/src/schemas/define-schema.ts b/src/schemas/define-schema.ts index 577ede6..779b9ec 100644 --- a/src/schemas/define-schema.ts +++ b/src/schemas/define-schema.ts @@ -3,7 +3,9 @@ import type { z } from "zod"; /** * Attach an `.is()` type guard to a Zod schema so schema, inferred type, and runtime guard derive from one definition (single source of truth). `Schema.parse()` at JSON boundaries; `Schema.is()` for narrowing. */ -export function defineSchema(schema: T) { +export function defineSchema( + schema: T, +): T & { is: (value: unknown) => value is z.infer } { // Object.assign is required over spread here: a Zod schema is a class instance, and spreading it would drop the prototype (parse, safeParse, refinements), so the guard is deliberately attached to the live instance. The no-object-assign rule is disabled for this file in eslint.config.ts. return Object.assign(schema, { is(value: unknown): value is z.infer { diff --git a/src/sea-entry.ts b/src/sea-entry.ts index 6a31db5..4c19ebc 100644 --- a/src/sea-entry.ts +++ b/src/sea-entry.ts @@ -1,11 +1,11 @@ import { main } from "./bin/main.js"; -// Node's single-executable application feature runs a CommonJS entry with -// no top-level await (https://nodejs.org/api/single-executable-applications.html): -// this is that entry, built separately from src/bin/cc-peer.ts as a fully -// bundled .cjs with zero external requires, since a SEA embeds no -// node_modules of its own. main stays genuinely async; only this top level -// is synchronous, which is all the CommonJS module system needs. +/* Node's single-executable application feature runs a CommonJS entry with + no top-level await (https://nodejs.org/api/single-executable-applications.html): + this is that entry, built separately from src/bin/cc-peer.ts as a fully + bundled .cjs with zero external requires, since a SEA embeds no + node_modules of its own. main stays genuinely async; only this top level + is synchronous, which is all the CommonJS module system needs. */ main().catch((error: unknown) => { process.stderr.write( `[cc-peer] fatal: ${error instanceof Error ? error.message : String(error)}\n`, diff --git a/src/test/socket-path.ts b/src/test/socket-path.ts index 30e02e5..db9462c 100644 --- a/src/test/socket-path.ts +++ b/src/test/socket-path.ts @@ -15,7 +15,9 @@ export function testSocketPath(home: string, label: string): string { .update(home) .digest("hex") .slice(0, NAMESPACE_HEX_LENGTH); + return `\\\\.\\pipe\\cc-peer-test-${namespace}-${label}`; } + return join(home, `${label}.sock`); } diff --git a/stryker.config.ts b/stryker.config.ts index fe2076c..096521f 100644 --- a/stryker.config.ts +++ b/stryker.config.ts @@ -1,14 +1,14 @@ const options = { testRunner: "vitest", - // Named explicitly rather than left to auto-discovery: a fresh pnpm install's strict, non-flat node_modules layout does not expose - // @stryker-mutator/vitest-runner the way auto-discovery expects, failing - // with "Cannot find TestRunner plugin vitest" on a clean CI checkout even though the identical config resolves it fine against an already-populated local node_modules. + /* Named explicitly rather than left to auto-discovery: a fresh pnpm install's strict, non-flat node_modules layout does not expose + @stryker-mutator/vitest-runner the way auto-discovery expects, failing + with "Cannot find TestRunner plugin vitest" on a clean CI checkout even though the identical config resolves it fine against an already-populated local node_modules. */ plugins: ["@stryker-mutator/vitest-runner"], reporters: ["html", "clear-text", "progress"], coverageAnalysis: "perTest", - // define-schema.ts is excluded, not just left to ignoreStatic: every schema module assigns defineSchema's return value straight to a top-level export const, so a mutant that empties defineSchema's body poisons every module that imports it and crashes test collection across ten-plus files at once. That triggers a still-open - // @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): - // the runner's collection-failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed, confirmed directly by reproducing it manually and watching a real vitest run correctly fail ten files while stryker still reported the mutant as surviving. ignoreStatic was tried first but is too broad a fix: every schema definition in this codebase is itself a top-level const, so it silently zeroes out mutation testing for the entire schemas directory, not just this one function. Excluding only define-schema.ts (a three-line helper already exercised indirectly by every other schema's own tests) keeps the rest of the schema layer's mutation score meaningful. + /* define-schema.ts is excluded, not just left to ignoreStatic: every schema module assigns defineSchema's return value straight to a top-level export const, so a mutant that empties defineSchema's body poisons every module that imports it and crashes test collection across ten-plus files at once. That triggers a still-open + @stryker-mutator/vitest-runner bug (stryker-mutator/stryker-js#6150): + the runner's collection-failure path never sets its own failure flag, so a mutant that crashes every test in a file at import time is misreported Survived instead of Killed, confirmed directly by reproducing it manually and watching a real vitest run correctly fail ten files while stryker still reported the mutant as surviving. ignoreStatic was tried first but is too broad a fix: every schema definition in this codebase is itself a top-level const, so it silently zeroes out mutation testing for the entire schemas directory, not just this one function. Excluding only define-schema.ts (a three-line helper already exercised indirectly by every other schema's own tests) keeps the rest of the schema layer's mutation score meaningful. */ mutate: [ "src/domain/**/*.ts", "src/schemas/**/*.ts", diff --git a/test/api.integration.test.ts b/test/api.integration.test.ts index 9624ee3..2fea6fa 100644 --- a/test/api.integration.test.ts +++ b/test/api.integration.test.ts @@ -50,8 +50,8 @@ describe("REST facade", () => { const unauthorized = await fetch(`${base}/sessions`); expect(unauthorized.status).toBe(401); - // fetch refuses to override the forbidden Host header, so probe the - // DNS-rebinding defence with a raw request that does set it. + /* fetch refuses to override the forbidden Host header, so probe the + DNS-rebinding defence with a raw request that does set it. */ const rebinding = await new Promise((resolve) => { const req = request( `${base}/healthz`, diff --git a/test/cli.integration.test.ts b/test/cli.integration.test.ts index bd9e1b0..b763018 100644 --- a/test/cli.integration.test.ts +++ b/test/cli.integration.test.ts @@ -17,6 +17,7 @@ async function runCli(args: readonly string[]): Promise<{ home: string; }> { const home = await mkdtemp(join(tmpdir(), "cc-peer-cli-")); + return new Promise((resolve, reject) => { const child = spawn(process.execPath, ["--import", "tsx", ENTRY, ...args], { env: { ...process.env, HOME: home, USERPROFILE: home }, diff --git a/test/sea.e2e.test.ts b/test/sea.e2e.test.ts index f79e83d..5b4dbef 100644 --- a/test/sea.e2e.test.ts +++ b/test/sea.e2e.test.ts @@ -27,6 +27,7 @@ async function freePort(): Promise { probe.close(() => { if (address === null || typeof address === "string") { reject(new Error("expected the probe server to report a port")); + return; } resolve(address.port); diff --git a/tsconfig.json b/tsconfig.json index c02c19b..335fa4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ { "compilerOptions": { - "target": "ES2024", + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "incremental": true, + "isolatedModules": true, + "lib": ["ES2024"], "module": "NodeNext", "moduleResolution": "nodenext", - "lib": ["ES2024"], - "types": ["node"], - "strict": true, - "resolveJsonModule": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, "noEmit": true, - "incremental": true, - "tsBuildInfoFile": ".tsbuildinfo", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, "skipLibCheck": true, - "isolatedModules": true, - "forceConsistentCasingInFileNames": true + "strict": true, + "target": "ES2024", + "tsBuildInfoFile": ".tsbuildinfo", + "types": ["node"] }, "include": ["src", "test", "scripts", "*.ts"] } diff --git a/turbo.json b/turbo.json index c264322..4ea4879 100644 --- a/turbo.json +++ b/turbo.json @@ -1,42 +1,21 @@ { "$schema": "https://turborepo.com/schema.json", + "agentGuidance": false, "tasks": { "_build": { - "dependsOn": [ - "^_build" - ], - "inputs": [ - "$TURBO_DEFAULT$", - "!src/**/*.test.ts" - ], - "outputs": [ - "dist/**" - ] + "dependsOn": ["^_build"], + "inputs": ["$TURBO_DEFAULT$", "!src/**/*.test.ts"], + "outputs": ["dist/**"] }, "_lint": { - "dependsOn": [ - "^_lint" - ], - "inputs": [ - "$TURBO_DEFAULT$", - ".eslintcache" - ], - "outputs": [ - ".eslintcache" - ] + "dependsOn": ["^_lint"], + "inputs": ["$TURBO_DEFAULT$", ".eslintcache"], + "outputs": [".eslintcache"] }, "_typecheck": { - "dependsOn": [ - "^_typecheck", - "_build" - ], - "inputs": [ - "$TURBO_DEFAULT$" - ], - "outputs": [ - ".tsbuildinfo" - ] + "dependsOn": ["^_typecheck", "_build"], + "inputs": ["$TURBO_DEFAULT$"], + "outputs": [".tsbuildinfo"] } - }, - "agentGuidance": false + } } diff --git a/vitest.config.ts b/vitest.config.ts index f797363..5ab14c9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config"; -// 100% thresholds: the test suite is the contract that every line and branch of src/ is exercised. Test files themselves are excluded from measurement. unit and integration are separate named projects so either tier can be run alone (pnpm test:unit / test:integration) for fast local iteration, but the default pnpm test / test:coverage runs both together with no --project filter — vitest merges their coverage into one map, preserving the same combined 100% gate this repo has always had. Splitting the gate itself is not viable here: cc-peer.ts's own orchestration and the Node adapters it wires together are legitimately proven by integration-tier tests exercising real sockets/processes, not by unit-tier fakes, so a unit-only coverage floor would only push toward over-mocking real component wiring rather than testing it. e2e (the packaged SEA binary) lives in its own vitest.e2e.config.ts entirely, never as a project here: it needs a just-built binary that exists only inside the sea CI job's own context. +/** + * 100% thresholds: the test suite is the contract that every line and branch of src/ is exercised. Test files themselves are excluded from measurement. unit and integration are separate named projects so either tier can be run alone (pnpm test:unit / test:integration) for fast local iteration, but the default pnpm test / test:coverage runs both together with no --project filter — vitest merges their coverage into one map, preserving the same combined 100% gate this repo has always had. Splitting the gate itself is not viable here: cc-peer.ts's own orchestration and the Node adapters it wires together are legitimately proven by integration-tier tests exercising real sockets/processes, not by unit-tier fakes, so a unit-only coverage floor would only push toward over-mocking real component wiring rather than testing it. e2e (the packaged SEA binary) lives in its own vitest.e2e.config.ts entirely, never as a project here: it needs a just-built binary that exists only inside the sea CI job's own context. + */ export default defineConfig({ test: { projects: [ diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 8862d3a..80e3cf2 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config"; -// Separate root config, not a project under vitest.config.ts: e2e tests need a just-built SEA binary (CC_PEER_SEA_BINARY) that only exists inside the sea CI job's own context, never when pnpm test/test:coverage run, so it must never be picked up by those default invocations. +/** + * Separate root config, not a project under vitest.config.ts: e2e tests need a just-built SEA binary (CC_PEER_SEA_BINARY) that only exists inside the sea CI job's own context, never when pnpm test/test:coverage run, so it must never be picked up by those default invocations. + */ export default defineConfig({ test: { include: ["test/**/*.e2e.test.ts"],