diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9939b83..92dbf0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,7 +529,7 @@ jobs: # Generate schema/*.schema.json fresh (it's already committed, but regenerating here keeps the release build honest — see the CI drift guard in the typecheck job above) then stamp $id to this tag's own version-pinned release URL. This is deliberately distinct from install.sh's unpinned releases/latest/download/... URL: a schema an editor references long-term must stay put at whatever version it was written against. - run: pnpm schema - - run: node scripts/stamp-schema-ids.mjs "${{ needs.semantic-release.outputs.tag }}" + - run: node scripts/stamp-schema-ids.mts "${{ needs.semantic-release.outputs.tag }}" - uses: actions/download-artifact@v8 with: diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..98d78df --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +minimum-release-age-exclude[]=@exadev/eslint-config diff --git a/README.md b/README.md index c894136..80a77c5 100644 --- a/README.md +++ b/README.md @@ -530,7 +530,7 @@ scripts/ build.mts # esbuild bundle -> node --build-sea= (see Build (Node SEA) below); --bundle-only stops after the bundle, for npm publishing gen-schema.mts # z.toJSONSchema() per exported schema -> schema/*.schema.json gen-schema-core.ts # shared schema-generation logic used by gen-schema.mts - stamp-schema-ids.mjs # rewrites $id to the real version-pinned release URL at publish time + stamp-schema-ids.mts # rewrites $id to the real version-pinned release URL at publish time .github/workflows/ ci.yml # one workflow: check (every push/PR) plus the whole release pipeline, gated to # tag pushes only — five platform builds, npm publish, GitHub Release, and the diff --git a/eslint-rules/no-pointless-reassignment.ts b/eslint-rules/no-pointless-reassignment.ts deleted file mode 100644 index c0babd8..0000000 --- a/eslint-rules/no-pointless-reassignment.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Rule, Scope } from "eslint"; - -type IdentifierNode = Extract; - -/** - * Detects and auto-fixes redundant alias declarations -- `const foo = bar` where both sides are plain identifiers and the alias adds no transformation. The fixer replaces all reads of the alias with the original name and removes the declaration. Variables prefixed with `_` are exempt (discard convention). Aliases that are written to after declaration are not auto-fixed (scope mutation), and only `const` is flagged -- a `let`/`var` alias is usually an intentional mutable copy. - */ -export const noPointlessReassignmentRule: Rule.RuleModule = { - meta: { - type: "problem", - fixable: "code", - messages: { - pointlessReassignment: "Pointless reassignment: '{{ name }}' is just an alias for '{{ value }}'. Use the original directly.", - }, - }, - create(context) { - return { - VariableDeclarator(node) { - if (node.id.type !== "Identifier" || node.init?.type !== "Identifier" || node.id.name.startsWith("_")) return; - if (node.parent.type !== "VariableDeclaration" || node.parent.kind !== "const") return; - - const aliasName = node.id.name; - const originalName = node.init.name; - - context.report({ - node, - messageId: "pointlessReassignment", - data: { name: aliasName, value: originalName }, - fix(fixer) { - const scope = context.sourceCode.getScope(node); - const variable = scope.set.get(aliasName); - if (!variable) return null; - - const mutationRefs = variable.references.filter((r) => r.isWrite() && r.identifier !== node.id); - if (mutationRefs.length > 0) return null; - - const readRefs = variable.references.filter((r): r is Scope.Reference & { identifier: IdentifierNode } => r.isRead() && r.identifier.type === "Identifier"); - if (readRefs.length !== variable.references.filter((r) => r.isRead()).length) return null; - - // Abort when any read is a shorthand property ({ x } from const x = y) -- rewriting { x } -> { x: original } needs a key change replaceText can't do safely. - const hasShorthand = readRefs.some((r) => { - const afterToken = context.sourceCode.getTokenAfter(r.identifier); - if (afterToken?.value === ":") return false; - if (afterToken?.value !== "}" && afterToken?.value !== ",") return false; - let tok = context.sourceCode.getTokenBefore(r.identifier); - while (tok) { - if (tok.value === "{") return true; - if (tok.value === "[" || tok.value === "(") return false; - if (tok.value === ":") return false; - tok = context.sourceCode.getTokenBefore(tok); - } - return false; - }); - if (hasShorthand) return null; - - const fixes = readRefs.map((r) => fixer.replaceText(r.identifier, originalName)); - - const declaration = node.parent; - if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return null; - fixes.push(fixer.remove(declaration)); - return fixes; - }, - }); - }, - }; - }, -}; diff --git a/eslint.config.ts b/eslint.config.ts index fd50b2a..3c7670d 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,46 +1,21 @@ -import js from "@eslint/js"; +import { defineConfig } from "eslint/config"; import globals from "globals"; -import tseslint from "typescript-eslint"; +import exadev from "@exadev/eslint-config"; -import { noPointlessReassignmentRule } from "./eslint-rules/no-pointless-reassignment"; - -// Bans re-export syntax (`export * from "..."`, `export { x } from "..."`) everywhere, no exceptions -- every consumer imports directly from the real source rather than through an intermediate re-export or barrel file. -const EXPORT_ALL_SELECTOR = "ExportAllDeclaration"; -const EXPORT_NAMED_SELECTOR = "ExportNamedDeclaration[source]"; -const RE_EXPORT_ALL_MESSAGE = "Re-exporting all exports is not allowed - import directly from the source."; -const RE_EXPORT_NAMED_MESSAGE = "Re-exporting is not allowed - import directly from the source."; - -export default tseslint.config( +export default defineConfig([ { ignores: ["dist", "coverage", "node_modules"] }, { languageOptions: { - // stamp-schema-ids.mjs is a plain utility script, never part of the type-checked TS project -- fall back to non-type-aware parsing for it instead of pulling it into tsconfig's include just to satisfy the type-aware project service. - parserOptions: { projectService: { allowDefaultProject: ["scripts/stamp-schema-ids.mjs"] }, tsconfigRootDir: import.meta.dirname }, + // Every file this project lints (src/, scripts/, the various *.config.ts files) is already listed in tsconfig.json's own include -- no allowDefaultProject fallback list needed now that stamp-schema-ids has been converted from .mjs to .mts, closing the one gap that used to require it. + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, globals: { ...globals.node }, }, }, - js.configs.recommended, - ...tseslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - ...tseslint.configs.stylisticTypeChecked, - { - // stamp-schema-ids.mjs has no real tsconfig-backed type information (see the allowDefaultProject note above) -- type-aware rules against it just fire noise (every Node built-in call reads as "unsafe" with no real type behind it), so drop the type-checked rule sets for it specifically rather than fighting that. - files: ["**/*.mjs"], - ...tseslint.configs.disableTypeChecked, - }, - { linterOptions: { noInlineConfig: true } }, + ...exadev, { - plugins: { local: { rules: { "no-pointless-reassignment": noPointlessReassignmentRule } } }, rules: { - "@typescript-eslint/ban-ts-comment": "error", - "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "never" }], + // exadev's own consistent-type-imports leaves fixStyle at the rule's default (separate-type-imports); this repo prefers the type keyword inline on the same import statement instead. "@typescript-eslint/consistent-type-imports": ["error", { fixStyle: "inline-type-imports" }], - "local/no-pointless-reassignment": "error", - "no-restricted-syntax": [ - "error", - { selector: EXPORT_ALL_SELECTOR, message: RE_EXPORT_ALL_MESSAGE }, - { selector: EXPORT_NAMED_SELECTOR, message: RE_EXPORT_NAMED_MESSAGE }, - ], }, }, -); +]); diff --git a/knip.config.ts b/knip.config.ts index 88100cf..f74e0e6 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -2,7 +2,7 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { entry: ["src/cli.ts"], - project: ["src/**/*.ts", "eslint-rules/**/*.ts"], + project: ["src/**/*.ts"], ignoreDependencies: [ // Referenced by preset/plugin name in release.config.ts, not by import -- knip can't trace this. "@semantic-release/npm", diff --git a/package.json b/package.json index a2954a9..e7cb2b1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "claude", "cli" ], - "packageManager": "pnpm@11.6.0", + "packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c", "type": "module", "engines": { "node": ">=22.12.0" @@ -54,7 +54,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.2", "@commitlint/config-conventional": "^21.2.2", - "@eslint/js": "^10.0.1", + "@exadev/eslint-config": "2.12.1", "@semantic-release/changelog": "^7.0.0", "@semantic-release/git": "^11.0.1", "@semantic-release/npm": "^13.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d2dca1..b83d51f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.1 + version: 12.4.1 + +packages: + + '@pnpm/exe.android-arm64@12.4.1': + resolution: {integrity: sha512-/HwsqXMSmlOfgtV9+O0ratzjV6Vd/8n1hh4rGHpCGmDURvt52MwZxdbhyxP4K03ZfvgSDvotFPr8O+RzE5Eu8A==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.1': + resolution: {integrity: sha512-+l74Qb4c2YjOzNKHXJLg+1wr8xHM1ckkUhnU5KRUK9TJqiiczt6yeTZqqzHIlJ8i6pAoj5V0OJevDRwnQKLgrQ==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.1': + resolution: {integrity: sha512-6rkZkT3iGfaxknUdGHraqSWFvTa6N0ajAHluv9Ax0GRWs0sIcGNiFhDopv6xSZCsJZmG483aNS/b6UEDy3blfw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.4.1': + resolution: {integrity: sha512-Vb1CHlR88HghC1qUxjxjs82zQSXnXacPD+btG2CmG8Q/hBU7Q0/b2YWJKSQqZXicunV5khFtfTlAwJddV9RkYA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.4.1': + resolution: {integrity: sha512-iT3iHz3Nl0Sxxj7UPOtZ/aQ81AFsQhjoeWMAlPkSRO04gsgDGAXv3UYxOFaesMWsfNxaGn0A+CITbuCHFF66FA==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.1': + resolution: {integrity: sha512-aBooZfNXM5f+OGUgCAMFWpE/kAhsWfvmqIyMtHy6zl3aNxyInWrcY/Saln/UElzo7lZWMC0Yktroxd/2J26lNQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.4.1': + resolution: {integrity: sha512-TlOdacTTP09BgcMvwWBFRsu8VAjfwqwnslBj+XSq1JFM3ck4f3k+1O/747EuctxZxs3/o785b6Q3s7Pd92Ptsg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.4.1': + resolution: {integrity: sha512-r/ab/MlIBo75oizUP5ITiziCCrnXz4SJwfErLQ+603AshB+Yq7xTMCoMtzTSCAMu6aaymIKkAFtZvd2J75Wq0w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.1': + resolution: {integrity: sha512-C/D1QWdKMiB8+wv/spl1rGITFUuqC+aI/fb6h8NB5sqxsOaGXeYc/g891oCuzggHn6SODk9p+I09hI76x/cMNw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.1': + resolution: {integrity: sha512-nxz5zD4yXt94uzbStDk0QTPKW+aE92hH1b5tFXK9ctB1HE9Xcq1vjTiA2LsZMFC6p4gdu5OwQeFqYNAVGa6QlA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.1': + resolution: {integrity: sha512-5AwgFdGhVUg2kIweYGfxzSLEHiIG77PQhZAkXC3TwofQHsu1Wr+TrV5/rNX2PopFnHRzuE581zoB8F6Wle32yg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.4.1': + resolution: {integrity: sha512-FJOZuuuQMhp0oLzBtcKkLXknBI92hfkmSnlKc47vfin4HrmfID5khY2lGekL9tCzk1cpR+HShEw/meFl+nHtzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.4.1': + resolution: {integrity: sha512-OO7eKBL9S+xk5hRy+JUUZSNJknGuGe3GEUffsrlC2LbqKF02g+VX1anGIzSbJDvkkdnpJhSlxF5AUz2JzJu2Jw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.4.1': + resolution: {integrity: sha512-x7gJHZgHo6hp354xCYA2NvoFzYJkHwovt2kVsUBK5EmXULLcP51JGMq09CX+5FRFJUZFKoXjLu3mZWmfP7o6PQ==} + cpu: [x64] + os: [win32] + + pnpm@12.4.1: + resolution: {integrity: sha512-LoHjmdc/6DkNqyXgaqeIq3pZCCSNL1o3D4K0gRR6ano2e/gEj5pv22Rg8hpm8FQt7bi5TKLIcjWWdBkgsWVtTA==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.4.1': + optional: true + + '@pnpm/exe.android-x64@12.4.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.1': + optional: true + + '@pnpm/exe.darwin-x64@12.4.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-arm64@12.4.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.4.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.4.1': + optional: true + + '@pnpm/exe.linux-s390x@12.4.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.4.1': + optional: true + + '@pnpm/exe.linux-x64@12.4.1': + optional: true + + '@pnpm/exe.win32-arm64@12.4.1': + optional: true + + '@pnpm/exe.win32-x64@12.4.1': + optional: true + + pnpm@12.4.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.4.1 + '@pnpm/exe.android-x64': 12.4.1 + '@pnpm/exe.darwin-arm64': 12.4.1 + '@pnpm/exe.darwin-x64': 12.4.1 + '@pnpm/exe.freebsd-x64': 12.4.1 + '@pnpm/exe.linux-arm64': 12.4.1 + '@pnpm/exe.linux-arm64-musl': 12.4.1 + '@pnpm/exe.linux-ppc64': 12.4.1 + '@pnpm/exe.linux-riscv64': 12.4.1 + '@pnpm/exe.linux-s390x': 12.4.1 + '@pnpm/exe.linux-x64': 12.4.1 + '@pnpm/exe.linux-x64-musl': 12.4.1 + '@pnpm/exe.win32-arm64': 12.4.1 + '@pnpm/exe.win32-x64': 12.4.1 + +--- lockfileVersion: '9.0' settings: @@ -35,9 +193,9 @@ importers: '@commitlint/config-conventional': specifier: ^21.2.2 version: 21.2.2 - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.10.0(jiti@2.7.0)) + '@exadev/eslint-config': + specifier: 2.12.1 + version: 2.12.1(eslint@10.10.0(jiti@2.7.0))(typescript-eslint@8.70.0(eslint@10.10.0(jiti@2.7.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(typescript@6.0.3)) @@ -247,6 +405,14 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@es-joy/jsdoccomment@0.97.0': + resolution: {integrity: sha512-EP8uoFfh6+GsdGCduYtmWAW0h7AO+Ayik9Vh5YbA2r/3N6lmJKkCNZX+q3QBXC1K6ixjQ/9igF2b7WVvLm063g==} + engines: {node: ^22.22.2 || >=24.15.0} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -442,6 +608,27 @@ 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'} + peerDependencies: + '@next/eslint-plugin-next': ^16.3.2 + eslint: '>=10.0.0' + eslint-plugin-jsx-a11y: ^6.10.2 + eslint-plugin-react: ^7.37.5 + eslint-plugin-react-hooks: ^7.1.1 + typescript: '>=4.8.4' + typescript-eslint: '>=8.0.0' + peerDependenciesMeta: + '@next/eslint-plugin-next': + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react: + optional: true + eslint-plugin-react-hooks: + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -481,6 +668,12 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -945,6 +1138,10 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1028,16 +1225,32 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.70.0': resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} 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} + '@typescript-eslint/scope-manager@8.70.0': resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} 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} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.70.0': resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1051,16 +1264,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.70.0': resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} 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} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.70.0': resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} 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} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.70.0': resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1068,6 +1298,10 @@ packages: 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} + '@typescript-eslint/visitor-keys@8.70.0': resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1131,6 +1365,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1161,6 +1398,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + are-docs-informative@0.1.1: + resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} + engines: {node: '>=18'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1263,6 +1504,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + comment-parser@1.4.8: + resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==} + engines: {node: '>= 12.0.0'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -1398,6 +1643,10 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1425,6 +1674,15 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-jsdoc@64.3.6: + resolution: {integrity: sha512-lo7IXmgUUNy88SxW7KnJmmD2iPQBIRMomfCFPHidW1M3zNNVuzxlB2uoNrNyE1g4v2/L+RtYmiROPwQhSNzL8Q==} + engines: {node: ^22.22.2 || >=24.15.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-tsdoc@0.5.2: + resolution: {integrity: sha512-BlvqjWZdBJDIPO/YU3zcPCF23CvjYT3gyu63yo6b609NNV3D1b6zceAREy2xnweuBoDpZcLNuPyAUq9cvx6bbQ==} + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1570,6 +1828,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-timeout@1.0.2: resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} engines: {node: '>=18'} @@ -1630,6 +1891,10 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -1651,6 +1916,9 @@ packages: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1719,6 +1987,10 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1789,6 +2061,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1799,6 +2074,10 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdoc-type-pratt-parser@9.2.1: + resolution: {integrity: sha512-V4Ww4EHnTcTLSOMoB0FsF72JhQvcAsriCm/LWnxJeGWoxIjEL2l9na11abQok5SYShq8m0Gl02el/xAbTCulvQ==} + engines: {node: ^22.22.2 || >=24.15.0} + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -2119,6 +2398,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: + resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} + obug@2.2.1: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} @@ -2189,6 +2471,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -2205,6 +2490,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -2230,6 +2518,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -2326,6 +2617,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2337,6 +2632,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2405,6 +2705,9 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} @@ -2483,6 +2786,10 @@ packages: resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -2528,6 +2835,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} engines: {node: '>= 0.4'} @@ -2996,6 +3307,16 @@ snapshots: tslib: 2.8.1 optional: true + '@es-joy/jsdoccomment@0.97.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.70.0 + comment-parser: 1.4.8 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 9.2.1 + + '@es-joy/resolve.exports@1.2.0': {} + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -3108,6 +3429,19 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exadev/eslint-config@2.12.1(eslint@10.10.0(jiti@2.7.0))(typescript-eslint@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3))(typescript@6.0.3)': + dependencies: + '@eslint/js': 10.0.1(eslint@10.10.0(jiti@2.7.0)) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0) + eslint-plugin-jsdoc: 64.3.6(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + eslint-plugin-tsdoc: 0.5.2(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + typescript-eslint: 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3141,6 +3475,15 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -3505,6 +3848,8 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@sindresorhus/base62@1.0.0': {} + '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -3583,6 +3928,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) @@ -3592,11 +3946,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/scope-manager@8.70.0': dependencies: '@typescript-eslint/types': 8.70.0 '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -3613,8 +3976,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/types@8.70.0': {} + '@typescript-eslint/typescript-estree@8.56.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@6.0.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + 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/typescript-estree@8.70.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) @@ -3630,6 +4010,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.56.1(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@6.0.3) + eslint: 10.10.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)) @@ -3641,6 +4032,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.70.0': dependencies: '@typescript-eslint/types': 8.70.0 @@ -3721,6 +4117,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -3748,6 +4151,8 @@ snapshots: any-promise@1.3.0: {} + are-docs-informative@0.1.1: {} + argparse@2.0.1: {} argue-cli@3.2.0: {} @@ -3850,6 +4255,8 @@ snapshots: commander@15.0.0: {} + comment-parser@1.4.8: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -3969,6 +4376,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} es-toolkit@1.52.0: {} @@ -4010,6 +4419,38 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-plugin-jsdoc@64.3.6(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@es-joy/jsdoccomment': 0.97.0 + '@es-joy/resolve.exports': 1.2.0 + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + are-docs-informative: 0.1.1 + comment-parser: 1.4.8 + debug: 4.4.3 + escape-string-regexp: 5.0.0 + eslint: 10.10.0(jiti@2.7.0) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.1 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 5.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-tsdoc@0.5.2(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@typescript-eslint/utils': 8.56.1(eslint@10.10.0(jiti@2.7.0))(typescript@6.0.3) + transitivePeerDependencies: + - eslint + - supports-color + - typescript + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -4204,6 +4645,8 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + function-timeout@1.0.2: {} get-caller-file@2.0.5: {} @@ -4254,6 +4697,10 @@ snapshots: dependencies: hookified: 1.15.1 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + highlight.js@10.7.3: {} hook-std@4.0.0: {} @@ -4270,6 +4717,8 @@ snapshots: dependencies: lru-cache: 11.5.2 + html-entities@2.6.0: {} + html-escaper@2.0.2: {} http-proxy-agent@9.1.0: @@ -4328,6 +4777,10 @@ snapshots: is-arrayish@0.2.1: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4379,6 +4832,8 @@ snapshots: jiti@2.7.0: {} + jju@1.4.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -4387,6 +4842,11 @@ snapshots: dependencies: argparse: 2.0.1 + jsdoc-type-pratt-parser@9.2.1: + dependencies: + '@types/estree': 1.0.9 + '@types/node': 26.5.1 + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} @@ -4621,6 +5081,8 @@ snapshots: object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} + obug@2.2.1: {} onetime@6.0.0: @@ -4722,6 +5184,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@4.0.0: dependencies: error-ex: 1.3.4 @@ -4742,6 +5208,8 @@ snapshots: parse-ms@4.0.0: {} + parse-statements@1.0.11: {} + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -4758,6 +5226,8 @@ snapshots: path-key@4.0.0: {} + path-parse@1.0.7: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -4852,12 +5322,21 @@ snapshots: require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -4960,6 +5439,11 @@ snapshots: spdx-exceptions: 2.5.0 spdx-license-ids: 3.0.23 + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + spdx-license-ids@3.0.23: {} split2@1.0.0: @@ -5035,6 +5519,8 @@ snapshots: has-flag: 4.0.0 supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} temp-dir@3.0.0: {} @@ -5078,6 +5564,11 @@ snapshots: dependencies: is-number: 7.0.0 + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + traverse@0.6.8: {} ts-api-utils@2.5.0(typescript@6.0.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1b3bf06..403c3f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,14 @@ allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - '@exadev/eslint-config@2.12.1' + # @semantic-release/release-notes-generator pins conventional-changelog-writer to # ^8.0.0, which predates the breaking Handlebars-to-render-function template rewrite that conventional-changelog-conventionalcommits@10.x now requires -- without this override, generateNotes silently produces an empty changelog body (just the version header, no Features/Bug Fixes sections). Confirmed upstream: https://github.com/semantic-release/release-notes-generator/issues/992 overrides: conventional-changelog-writer: ^9.2.0 js-yaml: ^4.3.1 nanoid: ^3.3.18 + +saveExact: true diff --git a/release.config.ts b/release.config.ts index c8a3bd4..5be818b 100644 --- a/release.config.ts +++ b/release.config.ts @@ -1,7 +1,7 @@ import type { Options } from "semantic-release"; /** - * Runs on `main`. Decides the next version from Conventional Commits (feat -> minor, fix/perf -> patch, a BREAKING CHANGE footer -> major), then creates and pushes the tag plus a chore(release) commit bumping CHANGELOG.md and package.json. @semantic-release/npm runs with npmPublish: false so it only bumps the version field -- actual npm publishing (OIDC trusted publishing), GitHub Release creation, and the Homebrew/Scoop tap updates are this project's own jobs in .github/workflows/ci.yml, not semantic-release plugins, since they need this project's own multi-platform asset list and release notes body rather than @semantic-release/github's generic ones. + * Runs on `main`. Decides the next version from Conventional Commits (a `feat` commit bumps minor, `fix`/`perf` bumps patch, a BREAKING CHANGE footer bumps major), then creates and pushes the tag plus a chore(release) commit bumping CHANGELOG.md and package.json. `@semantic-release/npm` runs with npmPublish: false so it only bumps the version field -- actual npm publishing (OIDC trusted publishing), GitHub Release creation, and the Homebrew/Scoop tap updates are this project's own jobs in .github/workflows/ci.yml, not semantic-release plugins, since they need this project's own multi-platform asset list and release notes body rather than `@semantic-release/github`'s generic ones. */ const config: Options = { branches: ["main"], diff --git a/scripts/build.mts b/scripts/build.mts index 7c9fb64..0db4870 100644 --- a/scripts/build.mts +++ b/scripts/build.mts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; /** - * Bundles `src/cli.ts` with esbuild into a single CJS file, then — unless `--bundle-only` is given — invokes the now-stable `node --build-sea=` single command (Node >= v25.5.0) to produce a self-contained single-executable-application binary. + * Bundles `src/cli.ts` with esbuild into a single CJS file, then — unless `--bundle-only` is given — invokes the now-stable `node --build-sea=` single command (Node v25.5.0 or later) to produce a self-contained single-executable-application binary. * * This deliberately does NOT use the older `--experimental-sea-config` + manual `postject` pipeline the README used to describe — `--build-sea` handles bundle-copy, signature removal, blob injection, and re-signing in one step, and postject is not a dependency of this project. * @@ -24,11 +24,19 @@ const rootDir = path.resolve(__dirname, ".."); const distDir = path.join(rootDir, "dist"); const bundleFileName = "cli.cjs"; const seaConfigFileName = "sea-config.json"; +const EXECUTABLE_FILE_MODE = 0o755; const outputBinaryName = process.platform === "win32" ? "claude-use-sea.exe" : "claude-use-sea"; +// node --build-sea's stable single-command form shipped in this release. +const MIN_BUILD_SEA_NODE_MAJOR = 25; +const MIN_BUILD_SEA_NODE_MINOR = 5; + function requireBuildSeaSupport(): void { const [major, minor] = process.versions.node.split(".").map((part) => Number.parseInt(part, 10)); - const supported = major !== undefined && minor !== undefined && (major > 25 || (major === 25 && minor >= 5)); + const supported = + major !== undefined && + minor !== undefined && + (major > MIN_BUILD_SEA_NODE_MAJOR || (major === MIN_BUILD_SEA_NODE_MAJOR && minor >= MIN_BUILD_SEA_NODE_MINOR)); if (!supported) { throw new Error( `node --build-sea requires Node >= v25.5.0 (this stable single-command form shipped there); ` + @@ -50,7 +58,7 @@ async function bundle(): Promise { minify: false, logLevel: "info", }); - fs.chmodSync(path.join(distDir, bundleFileName), 0o755); + fs.chmodSync(path.join(distDir, bundleFileName), EXECUTABLE_FILE_MODE); } function writeSeaConfig(): string { @@ -103,13 +111,16 @@ function buildSea(seaConfigPath: string): string { execFileSync("codesign", ["--sign", "-", outputPath], { stdio: "inherit" }); } - fs.chmodSync(outputPath, 0o755); + fs.chmodSync(outputPath, EXECUTABLE_FILE_MODE); return outputPath; } +const BYTES_PER_KIB = 1024; +const BYTES_PER_MIB = BYTES_PER_KIB * BYTES_PER_KIB; + function reportSize(outputPath: string): void { const { size } = fs.statSync(outputPath); - const mib = size / (1024 * 1024); + const mib = size / BYTES_PER_MIB; console.log(`Built ${outputPath} (${mib.toFixed(1)} MiB)`); } diff --git a/scripts/gen-schema-core.ts b/scripts/gen-schema-core.ts index b959213..75590fa 100644 --- a/scripts/gen-schema-core.ts +++ b/scripts/gen-schema-core.ts @@ -43,7 +43,7 @@ function main(): void { for (const [name, schema] of Object.entries(schemas)) { const jsonSchema = z.toJSONSchema(schema, { io: "input" }); - // A placeholder $id, later rewritten to a version-pinned GitHub Release asset URL by scripts/stamp-schema-ids.mjs at publish time — never left pointing at nothing, and never guessed at a real tag here, since this script has no notion of a release tag. + // A placeholder $id, later rewritten to a version-pinned GitHub Release asset URL by scripts/stamp-schema-ids.mts at publish time — never left pointing at nothing, and never guessed at a real tag here, since this script has no notion of a release tag. const withId = { $id: `https://github.com/ExaDev/claude-use/schema/${name}.schema.json`, ...jsonSchema }; const outPath = path.join(outDir, `${name}.schema.json`); fs.writeFileSync(outPath, `${JSON.stringify(withId, null, 2)}\n`); diff --git a/scripts/gen-schema.mts b/scripts/gen-schema.mts index 20be4de..a256e38 100644 --- a/scripts/gen-schema.mts +++ b/scripts/gen-schema.mts @@ -14,6 +14,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, ".."); const distDir = path.join(rootDir, "dist"); +function currentNodeMajor(): string { + const [major] = process.versions.node.split("."); + if (major === undefined) { + throw new Error(`Could not parse a major version from process.versions.node (${process.versions.node}).`); + } + return major; +} + async function main(): Promise { fs.mkdirSync(distDir, { recursive: true }); const outfile = path.join(distDir, "gen-schema-core.mjs"); @@ -23,7 +31,7 @@ async function main(): Promise { bundle: true, platform: "node", format: "esm", - target: `node${process.versions.node.split(".")[0]}`, + target: `node${currentNodeMajor()}`, outfile, external: ["zod"], logLevel: "info", diff --git a/scripts/stamp-schema-ids.mjs b/scripts/stamp-schema-ids.mts similarity index 75% rename from scripts/stamp-schema-ids.mjs rename to scripts/stamp-schema-ids.mts index 8ecb62d..7411d3f 100644 --- a/scripts/stamp-schema-ids.mjs +++ b/scripts/stamp-schema-ids.mts @@ -10,14 +10,18 @@ import { fileURLToPath } from "node:url"; * * Run at publish time only (from `release.yml`, right before the schema files are uploaded as release assets), never as part of `pnpm schema` — `scripts/gen-schema.mts` writes a placeholder `$id` with no notion of a release tag, and this script's rewrite is a separate, deliberate step layered on top of that output. * - * Usage: node scripts/stamp-schema-ids.mjs The tag may also come from the `GITHUB_REF_NAME` environment variable (as GitHub Actions sets it for a tag-triggered workflow run), used when no argv tag is given. + * Usage: node scripts/stamp-schema-ids.mts The tag may also come from the `GITHUB_REF_NAME` environment variable (as GitHub Actions sets it for a tag-triggered workflow run), used when no argv tag is given. */ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, ".."); const schemaDir = path.join(rootDir, "schema"); -function resolveTag() { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveTag(): string { const argTag = process.argv[2]; if (argTag !== undefined && argTag.length > 0) { return argTag; @@ -27,11 +31,11 @@ function resolveTag() { return envTag; } throw new Error( - "No release tag given. Pass one as the first argument (node scripts/stamp-schema-ids.mjs v1.2.3) or set GITHUB_REF_NAME.", + "No release tag given. Pass one as the first argument (node scripts/stamp-schema-ids.mts v1.2.3) or set GITHUB_REF_NAME.", ); } -function main() { +function main(): void { const tag = resolveTag(); const files = fs.readdirSync(schemaDir).filter((name) => name.endsWith(".schema.json")); if (files.length === 0) { @@ -41,13 +45,14 @@ function main() { for (const file of files) { const filePath = path.join(schemaDir, file); const raw = fs.readFileSync(filePath, "utf8"); - const parsed = JSON.parse(raw); - const stamped = { - ...parsed, - $id: `https://github.com/ExaDev/claude-use/releases/download/${tag}/${file}`, - }; + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed)) { + throw new Error(`${filePath} does not contain a JSON object at its top level.`); + } + const id = `https://github.com/ExaDev/claude-use/releases/download/${tag}/${file}`; + const stamped = { ...parsed, $id: id }; fs.writeFileSync(filePath, `${JSON.stringify(stamped, null, 2)}\n`); - console.log(`Stamped ${file} -> ${stamped.$id}`); + console.log(`Stamped ${file} -> ${id}`); } } diff --git a/src/check.ts b/src/check.ts index 8f78b23..98b169b 100644 --- a/src/check.ts +++ b/src/check.ts @@ -89,7 +89,7 @@ export function formatDecision(decision: Decision): string { reason = decision.rule === undefined ? "an entries rule" - : `entries rule "${decision.rule.rawKey}" from layer ${decision.rule.layer}`; + : `entries rule "${decision.rule.rawKey}" from layer ${String(decision.rule.layer)}`; break; case "category-override": reason = `category "${decision.category ?? "?"}" overridden by a layer`; @@ -100,7 +100,7 @@ export function formatDecision(decision: Decision): string { } const eliminatedNote = decision.eliminated !== undefined && decision.eliminated.length > 0 - ? ` [${decision.eliminated.length} more specific rule(s) eliminated by a failing when-condition]` + ? ` [${String(decision.eliminated.length)} more specific rule(s) eliminated by a failing when-condition]` : ""; return `${decision.relPath}: ${status} — ${reason}${eliminatedNote}`; } @@ -130,7 +130,7 @@ export function lookupKeychainService(run: RunPort, farmRoot: string): KeychainL return { checked: true, found: false, - note: `No Keychain entry found for account "${farmRoot}" (security exited ${result.status ?? "with no status"}).`, + note: `No Keychain entry found for account "${farmRoot}" (security exited ${result.status === null ? "with no status" : String(result.status)}).`, }; } const serviceName = parseKeychainServiceName(result.stderr); @@ -333,7 +333,7 @@ export function formatCheckReport(report: CheckReport): string[] { lines.push("", "Layers (shallowest/earliest first):"); for (const layer of report.resolved.assembled.layers) { - lines.push(` [${layer.id}] ${layer.kind}: ${layer.source}`); + lines.push(` [${String(layer.id)}] ${layer.kind}: ${layer.source}`); } lines.push("", "Resolved entries:"); @@ -377,9 +377,9 @@ export function formatCheckReport(report: CheckReport): string[] { lines.push("", "Settings exposure (names and counts only, never values):"); for (const exposure of report.settingsExposure) { lines.push( - ` ${exposure.file}: ${exposure.envKeyNames.length} env key(s) [${exposure.envKeyNames.join(", ")}], ` + - `${exposure.hookEventNames.length} hook event(s) [${exposure.hookEventNames.join(", ")}], ` + - `${exposure.hookCommandCount} hook command(s)`, + ` ${exposure.file}: ${String(exposure.envKeyNames.length)} env key(s) [${exposure.envKeyNames.join(", ")}], ` + + `${String(exposure.hookEventNames.length)} hook event(s) [${exposure.hookEventNames.join(", ")}], ` + + `${String(exposure.hookCommandCount)} hook command(s)`, ); } } @@ -397,7 +397,7 @@ export function registerCheckCommand(program: Command, paths: LayoutPaths): void .command("check [path]") .description("Show the resolved cascade for a directory/identity, plus always-on diagnostics. Never touches the farm or spawns claude.") .option("--identity ", "Identity to check (defaults to the identity a real launch would resolve).") - .action((pathArg: string | undefined, options: { identity?: string }) => { + .action((pathArg: string | undefined, options: Readonly<{ identity?: string }>) => { const cwd = pathArg === undefined ? process.cwd() : path.resolve(pathArg); const home = os.homedir(); const claudeHome = resolveClaudeHome(); diff --git a/src/claudeShim.test.ts b/src/claudeShim.test.ts index 7db26d8..4834626 100644 --- a/src/claudeShim.test.ts +++ b/src/claudeShim.test.ts @@ -17,6 +17,19 @@ import { } from "./claudeShim"; import { buildLayoutPaths, type LayoutPaths } from "./paths"; +// The owner/group/other execute bits -- a nonzero result means at least one of the three "may execute" bits is set. +const EXECUTE_BITS_MASK = 0o111; + +// A minimal NodeJS.ErrnoException-shaped error for tests that need to simulate a specific fs error code -- a real class property, not Object.assign onto a constructed instance, so the code field is type-checked like any other. +class CodedError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + } +} + describe("claudeShim", () => { let root: string; let paths: LayoutPaths; @@ -90,7 +103,7 @@ describe("claudeShim", () => { expect(result.method).toBe("hardlink"); expect(result.targetPath).toBe(path.join(binDir, "claude")); expect(fs.readFileSync(result.targetPath, "utf8")).toBe("fake-binary-v1"); - expect(fs.statSync(result.targetPath).mode & 0o111).not.toBe(0); + expect(fs.statSync(result.targetPath).mode & EXECUTE_BITS_MASK).not.toBe(0); }); it("places the shim next to a PATH-visible symlink, not next to its realpath target (Homebrew's Cellar layout)", () => { @@ -202,7 +215,7 @@ describe("claudeShim", () => { const fakeLinkFs: LinkFs = { link: () => { calls.push("link"); - throw Object.assign(new Error("cross-device"), { code: "EXDEV" }); + throw new CodedError("cross-device", "EXDEV"); }, copyFile: (src, dest) => { calls.push("copyFile"); @@ -221,7 +234,7 @@ describe("claudeShim", () => { it("propagates any other link error unchanged, without falling back", () => { const fakeLinkFs: LinkFs = { link: () => { - throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + throw new CodedError("permission denied", "EACCES"); }, copyFile: () => { throw new Error("should not be called"); diff --git a/src/claudeShim.ts b/src/claudeShim.ts index 644480f..f87c358 100644 --- a/src/claudeShim.ts +++ b/src/claudeShim.ts @@ -9,6 +9,9 @@ import { CliError } from "./cliError"; import type { LayoutPaths } from "./paths"; /** The claude-shim.json marker's own shape: never hand-edited, so it lives here rather than in `src/config/schema.ts`'s user-editable schemas (and is correctly excluded from `scripts/gen-schema.mts`'s published-schema generation, which only ever imports from that file). */ +// rwxr-xr-x -- the copy-fallback path below sets this explicitly since a plain copyFile doesn't preserve the source's own executable bit the way a hardlink (which shares the same inode) does. +const EXECUTABLE_FILE_MODE = 0o755; + export const ClaudeShimStateSchema = z.strictObject({ targetPath: z.string().min(1), method: z.enum(["hardlink", "copy"]), @@ -167,7 +170,7 @@ export function enableClaudeShim(params: EnableShimParams, linkFs: LinkFs = node throw error; } linkFs.copyFile(realContentPath, targetPath); - linkFs.chmod(targetPath, 0o755); + linkFs.chmod(targetPath, EXECUTABLE_FILE_MODE); method = "copy"; } @@ -265,7 +268,7 @@ export function registerShimCommand(program: Command, paths: LayoutPaths): void .description("Create a `claude`-named copy of this same executable, alongside claude-use by default.") .option("--dir ", "Enable into this directory instead of alongside the running claude-use executable.") .option("--force", "Overwrite the target even if it doesn't look like claude-use's own doing.") - .action((options: { dir?: string; force?: boolean }) => { + .action((options: Readonly<{ dir?: string; force?: boolean }>) => { const ownExecutablePath = realOwnExecutablePath(); const contentSourcePath = realContentSourcePath(); const result = enableClaudeShim({ @@ -299,7 +302,7 @@ export function registerShimCommand(program: Command, paths: LayoutPaths): void .description("Remove the `claude` command shim `shim enable` previously created. A no-op, not an error, if none is enabled.") .option("--dir ", "Look in this directory instead of trusting the recorded location.") .option("--force", "Remove the target even if it doesn't look like claude-use's own doing.") - .action((options: { dir?: string; force?: boolean }) => { + .action((options: Readonly<{ dir?: string; force?: boolean }>) => { const ownExecutablePath = realOwnExecutablePath(); const contentSourcePath = realContentSourcePath(); const result = disableClaudeShim({ diff --git a/src/cli.ts b/src/cli.ts index 6e8cae9..123f6f0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,7 +67,7 @@ function buildClaudeUseProgram(): Command { .allowUnknownOption() .helpOption(false) .argument("[args...]", "Arguments to forward, e.g. @, --config-profile , or any Claude Code flag.") - .action(async (args: string[]) => { + .action(async (args: readonly string[]) => { await runClaude(args); }); @@ -108,7 +108,7 @@ function buildFarmRuntime(paths: LayoutPaths): { ...(cliOverride === undefined ? {} : { cliOverride }), }).input, now: () => Date.now(), - uniqueSuffix: `${process.pid}.${randomUUID()}`, + uniqueSuffix: `${String(process.pid)}.${randomUUID()}`, lock: { pid: process.pid, isProcessAlive: realIsProcessAlive, sleep: realSleepSync }, }, ...(selections.identity === undefined ? {} : { directoryIdentity: selections.identity }), diff --git a/src/cli/parsers.ts b/src/cli/parsers.ts index d9e81d8..8e347e4 100644 --- a/src/cli/parsers.ts +++ b/src/cli/parsers.ts @@ -48,7 +48,7 @@ export function parseBoolStrict(input: string): boolean { } /** - * Parses a comma-separated list of `=` pairs into a plain object, e.g. `"history=true,knowledge=false"` -> `{ history: true, knowledge: false }`. + * Parses a comma-separated list of `=` pairs into a plain object, e.g. `"history=true,knowledge=false"` becomes `{ history: true, knowledge: false }`. * * An empty string parses to `{}`. A key repeated within the same list is not an error — the later occurrence in the string wins, matching how a plain object literal with a repeated key behaves. */ @@ -64,6 +64,6 @@ export function parseBoolPairList(input: string): Record { /** * Commander repeatable-option collector for a `--flag "a=true,b=false"`-shaped option: parses `value` and merges it over `previous`, so `--category history=true --category knowledge=false` (two separate invocations) accumulates into one object, later invocations winning on key collision — the same convention Commander's own repeatable-option examples use for arrays, applied to a merged object instead. */ -export function collectBoolPairs(value: string, previous: Record = {}): Record { +export function collectBoolPairs(value: string, previous: Readonly> = {}): Record { return { ...previous, ...parseBoolPairList(value) }; } diff --git a/src/cliError.test.ts b/src/cliError.test.ts index b5fe006..a623be3 100644 --- a/src/cliError.test.ts +++ b/src/cliError.test.ts @@ -11,6 +11,9 @@ import { IdentityLockBusyError } from "./launcher/lock"; import { UnrootedProjectPathError } from "./resolve/projects"; import { EntryKeyError } from "./resolve/match"; +// An arbitrary fake PID, used only as a fixture for IdentityLockBusyError below. +const FAKE_LOCK_HOLDER_PID = 42; + /** * Every custom error this CLI throws to represent an expected, user-facing failure must extend `CliError` -- that is what makes `main()` in `src/cli.ts` print it as a clean one-line message instead of a raw stack trace. This test exists specifically to catch a class silently reverting to `extends Error`, or a new one being added without extending `CliError` at all, neither of which `tsc`/`eslint` would ever flag. */ @@ -29,7 +32,7 @@ describe("every CLI-facing error class extends CliError", () => { ["ConfigValidationError", () => new ConfigValidationError("/some/config.json", [])], ["InvalidCliCategoryError", () => new InvalidCliCategoryError("secret")], ["InvalidCliEntryKeyError", () => new InvalidCliEntryKeyError("no-prefix")], - ["IdentityLockBusyError", () => new IdentityLockBusyError("work", "/some/lock", 42)], + ["IdentityLockBusyError", () => new IdentityLockBusyError("work", "/some/lock", FAKE_LOCK_HOLDER_PID)], ["UnrootedProjectPathError", () => new UnrootedProjectPathError("relative/path")], ["EntryKeyError", () => new EntryKeyError("bad-key", "bad", "malformed")], ])("%s extends CliError", (_name, construct) => { diff --git a/src/config/load.ts b/src/config/load.ts index 9115910..38cf5d3 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -70,7 +70,7 @@ export class ConfigValidationError extends CliError { export type ConfigFileReader = (filepath: string) => unknown; /** A cosmiconfig-backed reader: loads and parses one file by path, returning undefined when it is missing or empty. */ -export function cosmiconfigReader(explorer: PublicExplorerSync = createExplorer()): ConfigFileReader { +export function cosmiconfigReader(explorer: Readonly = createExplorer()): ConfigFileReader { return (filepath: string): unknown => { let result; try { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index b38c384..c36384d 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -192,14 +192,13 @@ describe("DirectoryRuleSchema", () => { describe("DirectoryRulesSchema", () => { it("parses the README's own example rules file", () => { - const parsed = DirectoryRulesSchema.parse({ - rules: [ - { path: "~/work", configProfile: "work-default" }, - { path: "~/work/clients", configProfile: "client-strict", identity: "work" }, - { path: "~/work/clients/example", entries: { "knowledge/skills/example-notes": true } }, - ], - }); - expect(parsed.rules).toHaveLength(3); + const rules = [ + { path: "~/work", configProfile: "work-default" }, + { path: "~/work/clients", configProfile: "client-strict", identity: "work" }, + { path: "~/work/clients/example", entries: { "knowledge/skills/example-notes": true } }, + ]; + const parsed = DirectoryRulesSchema.parse({ rules }); + expect(parsed.rules).toHaveLength(rules.length); }); }); diff --git a/src/config/store.test.ts b/src/config/store.test.ts index 77e1861..6e7a38f 100644 --- a/src/config/store.test.ts +++ b/src/config/store.test.ts @@ -80,7 +80,7 @@ describe("store.ts", () => { }, }; - expect(() => writeJsonAtomic(filePath, { name: "should-never-land" }, crashingRenameFs)).toThrow( + expect(() => { writeJsonAtomic(filePath, { name: "should-never-land" }, crashingRenameFs); }).toThrow( "simulated crash", ); @@ -104,7 +104,11 @@ describe("store.ts", () => { }; writeJsonAtomic(filePath, { name: "acme" }, observingFs); expect(seenTempPaths).toHaveLength(1); - expect(path.dirname(seenTempPaths[0]!)).toBe(dir); + const [tempPath] = seenTempPaths; + if (tempPath === undefined) { + throw new Error("Expected at least one observed temp path."); + } + expect(path.dirname(tempPath)).toBe(dir); }); it("writeTextAtomic writes plain text, not JSON-wrapped", () => { diff --git a/src/config/store.ts b/src/config/store.ts index a221bdd..4778136 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -92,7 +92,7 @@ export function readJson( export function writeTextAtomic(filePath: string, contents: string, storeFs: StoreFs = nodeStoreFs): void { const dir = path.dirname(filePath); storeFs.mkdirSync(dir); - const tempPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`); + const tempPath = path.join(dir, `.${path.basename(filePath)}.${String(process.pid)}.${randomUUID()}.tmp`); storeFs.writeFileUtf8(tempPath, contents); try { storeFs.renameSync(tempPath, filePath); @@ -124,7 +124,7 @@ export interface ApplyPatchOptions { export function applyPatch( filePath: string, schema: S, - patch: Partial>, + patch: Readonly>>, options: ApplyPatchOptions> = {}, ): z.infer { const storeFs = options.storeFs ?? nodeStoreFs; diff --git a/src/configProfiles.ts b/src/configProfiles.ts index 21519b5..effab0f 100644 --- a/src/configProfiles.ts +++ b/src/configProfiles.ts @@ -167,7 +167,7 @@ export function setProfileEntries(paths: LayoutPaths, name: string, patch: Reado } /** Merges `patch` into `profile`'s own `launch` object and writes it back. */ -export function setProfileLaunchFlags(paths: LayoutPaths, name: string, patch: LaunchFlags): ConfigProfile { +export function setProfileLaunchFlags(paths: LayoutPaths, name: string, patch: Readonly): ConfigProfile { requireProfileExists(paths, name); const existing = readProfile(paths, name) ?? {}; const mergedLaunch: LaunchFlags = { ...existing.launch, ...patch }; @@ -189,7 +189,7 @@ export function registerProfileCommand(program: Command, paths: LayoutPaths): vo .command("create ") .description("Create a new, empty configuration profile.") .option("--extends ", "Comma-separated list of profile names this one extends.") - .action((name: string, options: { extends?: string }) => { + .action((name: string, options: Readonly<{ extends?: string }>) => { const extendsList = options.extends !== undefined && options.extends !== "" ? options.extends.split(",") : undefined; createProfile(paths, name, extendsList); console.log(`Created configuration profile "${name}".`); diff --git a/src/configure.test.ts b/src/configure.test.ts index 1be6f07..fe06c22 100644 --- a/src/configure.test.ts +++ b/src/configure.test.ts @@ -33,7 +33,7 @@ function scriptedPrompts(answers: readonly unknown[]): { readonly port: PromptsP return value; }; const port: PromptsPort = { - select: (params: SelectParams): Promise => { + select: async (params: SelectParams): Promise => { messages.push(params.message); const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); @@ -41,7 +41,7 @@ function scriptedPrompts(answers: readonly unknown[]): { readonly port: PromptsP if (option === undefined) throw new Error(`scripted select answer not in options: ${String(answer)}`); return Promise.resolve(option.value); }, - multiselect: (params: MultiselectParams): Promise => { + multiselect: async (params: MultiselectParams): Promise => { messages.push(params.message); const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); @@ -54,7 +54,7 @@ function scriptedPrompts(answers: readonly unknown[]): { readonly port: PromptsP } return Promise.resolve(selected); }, - text: (params: TextParams): Promise => { + text: async (params: TextParams): Promise => { messages.push(params.message); const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); @@ -62,16 +62,16 @@ function scriptedPrompts(answers: readonly unknown[]): { readonly port: PromptsP return Promise.resolve(answer); }, isCancel: (value): value is symbol => typeof value === "symbol", - cancel: (message) => lifecycleCalls.push(`cancel:${message ?? ""}`), - intro: (message) => lifecycleCalls.push(`intro:${message ?? ""}`), - outro: (message) => lifecycleCalls.push(`outro:${message ?? ""}`), + cancel: (message) => { lifecycleCalls.push(`cancel:${message ?? ""}`); }, + intro: (message) => { lifecycleCalls.push(`intro:${message ?? ""}`); }, + outro: (message) => { lifecycleCalls.push(`outro:${message ?? ""}`); }, }; return { port, messages }; } function makeLog(): { readonly log: { info: (message: string) => void }; readonly lines: string[] } { const lines: string[] = []; - return { log: { info: (message: string) => lines.push(message) }, lines }; + return { log: { info: (message: string) => { lines.push(message); } }, lines }; } describe("chooseWriteTarget", () => { diff --git a/src/configure.ts b/src/configure.ts index 312158b..557ee03 100644 --- a/src/configure.ts +++ b/src/configure.ts @@ -100,7 +100,7 @@ function isKnownOptionValue(value: string, options: readon } export const realPromptsPort: PromptsPort = { - select: (params: SelectParams) => + select: async (params: SelectParams) => clack .select({ message: params.message, @@ -113,7 +113,7 @@ export const realPromptsPort: PromptsPort = { } throw new Error(`@clack/prompts select() returned a value not present in the given options: ${value}`); }), - multiselect: (params: MultiselectParams) => + multiselect: async (params: MultiselectParams) => clack .multiselect({ message: params.message, @@ -126,7 +126,7 @@ export const realPromptsPort: PromptsPort = { } throw new Error(`@clack/prompts multiselect() returned a value not present in the given options: ${value.join(", ")}`); }), - text: (params: TextParams) => + text: async (params: TextParams) => clack.text({ message: params.message, ...(params.placeholder === undefined ? {} : { placeholder: params.placeholder }), @@ -219,6 +219,8 @@ export function describeWriteTarget(target: WriteTarget): string { return `directory rule for "${target.rulePath}"`; case "config-profile": return `configuration profile "${target.profileName}"`; + default: + return target satisfies never; } } @@ -237,10 +239,10 @@ function writeCategoryPatch(paths: LayoutPaths, target: WriteTarget, patch: Read case "directory-rule": { const rules = readDirectoryRules(paths); const index = rules.rules.findIndex((rule) => rule.path === target.rulePath); - if (index === -1) { + const existingRule = index === -1 ? undefined : rules.rules[index]; + if (existingRule === undefined) { throw new Error(`Directory rule for "${target.rulePath}" no longer exists.`); } - const existingRule = rules.rules[index]!; const merged: CategoryMap = { ...existingRule.categories, ...patch }; const nextRules = [...rules.rules]; nextRules[index] = { ...existingRule, categories: merged }; @@ -264,10 +266,10 @@ function writeEntriesPatch(paths: LayoutPaths, target: WriteTarget, patch: Reado case "directory-rule": { const rules = readDirectoryRules(paths); const index = rules.rules.findIndex((rule) => rule.path === target.rulePath); - if (index === -1) { + const existingRule = index === -1 ? undefined : rules.rules[index]; + if (existingRule === undefined) { throw new Error(`Directory rule for "${target.rulePath}" no longer exists.`); } - const existingRule = rules.rules[index]!; const merged: Entries = { ...existingRule.entries, ...patch }; const nextRules = [...rules.rules]; nextRules[index] = { ...existingRule, entries: merged }; @@ -468,7 +470,7 @@ export function validateProfileName(value: string, existingNames?: readonly stri if (!PROFILE_NAME_RE.test(value)) { return "Names must start with a letter or digit, and contain only letters, digits, dots, dashes, underscores, and at signs."; } - if (existingNames?.includes(value)) { + if (existingNames?.includes(value) ?? false) { return `A configuration profile named "${value}" already exists.`; } return undefined; @@ -730,7 +732,7 @@ export function registerConfigureCommand(program: Command, paths: LayoutPaths): ) .action(async (identityName: string, pathArg: string | undefined) => { await runConfigure( - { paths, prompts: realPromptsPort, log: { info: (message: string) => console.log(message) } }, + { paths, prompts: realPromptsPort, log: { info: (message: string) => { console.log(message); } } }, { identityName, ...(pathArg === undefined ? {} : { path: pathArg }), diff --git a/src/directoryRules.test.ts b/src/directoryRules.test.ts index 48ef87a..7cef5ef 100644 --- a/src/directoryRules.test.ts +++ b/src/directoryRules.test.ts @@ -63,7 +63,7 @@ describe("directoryRules", () => { }); it("throws ConfigValidationError, not a raw ZodError, for a rule set that fails DirectoryRulesSchema", () => { - expect(() => writeDirectoryRules(paths, { rules: [{ path: "" }] })).toThrow(ConfigValidationError); + expect(() => { writeDirectoryRules(paths, { rules: [{ path: "" }] }); }).toThrow(ConfigValidationError); }); it("appends a second rule for a different path", () => { @@ -103,11 +103,11 @@ describe("directoryRules", () => { }); it("throws DirectoryRuleNotFoundError when no rule matches", () => { - expect(() => removeDirectoryRule(paths, "~/nonexistent")).toThrow(DirectoryRuleNotFoundError); + expect(() => { removeDirectoryRule(paths, "~/nonexistent"); }).toThrow(DirectoryRuleNotFoundError); }); it("throws DirectoryRuleNotFoundError when the file does not exist at all", () => { - expect(() => removeDirectoryRule(paths, "~/nonexistent")).toThrow(DirectoryRuleNotFoundError); + expect(() => { removeDirectoryRule(paths, "~/nonexistent"); }).toThrow(DirectoryRuleNotFoundError); }); }); }); diff --git a/src/directoryRules.ts b/src/directoryRules.ts index 55074ba..9f42e5e 100644 --- a/src/directoryRules.ts +++ b/src/directoryRules.ts @@ -66,7 +66,10 @@ export function addDirectoryRule(paths: LayoutPaths, rulePath: string, options: updated = buildNewRule(rulePath, options); writeDirectoryRules(paths, { ...current, rules: [...current.rules, updated] }); } else { - const existingRule = current.rules[existingIndex]!; + const existingRule = current.rules[existingIndex]; + if (existingRule === undefined) { + throw new Error(`Directory rule at index ${String(existingIndex)} unexpectedly missing.`); + } updated = { ...existingRule, ...(options.configProfile !== undefined ? { configProfile: options.configProfile } : {}), @@ -106,7 +109,7 @@ export function registerRulesCommand(program: Command, paths: LayoutPaths): void .description("Add or update a directory rule.") .option("--profile ", "Configuration profile to select for this path.") .option("--identity ", "Identity to pin for this path.") - .action(async (rulePath: string, options: { profile?: string; identity?: string }) => { + .action(async (rulePath: string, options: Readonly<{ profile?: string; identity?: string }>) => { let profileName = options.profile; if (profileName !== undefined && readProfile(paths, profileName) === undefined) { const result = await runProfileWizard(realPromptsPort, { paths, defaultNewName: profileName }); @@ -114,9 +117,9 @@ export function registerRulesCommand(program: Command, paths: LayoutPaths): void console.log(`No configuration profile named "${profileName}" was created; the rule pins identity only.`); profileName = undefined; } else { - if (result.name !== options.profile) { + if (result.name !== profileName) { console.log( - `Created configuration profile "${result.name}" instead of "${options.profile}". The rule selects that name.`, + `Created configuration profile "${result.name}" instead of "${profileName}". The rule selects that name.`, ); } profileName = result.name; diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 35affea..d4bf9b7 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -249,7 +249,7 @@ describe("runDoctor: config-profile extends chain", () => { }), ); const failures = findingsFor(report, "config-profile").filter((finding) => finding.severity === "fail"); - expect(failures.map((finding) => finding.subject).sort()).toEqual(["a", "b"]); + expect(failures.map((finding) => finding.subject).sort((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["a", "b"]); expect(failures.every((finding) => finding.message.includes("Circular"))).toBe(true); }); @@ -438,19 +438,17 @@ describe("runDoctor: aggregation contract", () => { binaryDiscovery: { ok: false, message: "not found" }, }); - let report: ReturnType | undefined; - expect(() => { - report = runDoctor(params); - }).not.toThrow(); - - expect(report?.ok).toBe(false); - expect(findingsFor(report!, "identity")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "config-profile")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "directory-rules")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "global-config")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "categories-local")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "active-identity")[0]?.severity).toBe("fail"); - expect(findingsFor(report!, "binary-discovery")[0]?.severity).toBe("fail"); + // A genuine throw here fails the test on its own -- no separate not.toThrow() wrapper needed, which also avoids report ever being possibly-undefined below. + const report = runDoctor(params); + + expect(report.ok).toBe(false); + expect(findingsFor(report, "identity")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "config-profile")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "directory-rules")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "global-config")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "categories-local")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "active-identity")[0]?.severity).toBe("fail"); + expect(findingsFor(report, "binary-discovery")[0]?.severity).toBe("fail"); }); it("is ok=false iff at least one finding is fail, regardless of any number of warn findings", () => { diff --git a/src/doctor.ts b/src/doctor.ts index 82d06c6..661ceb2 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -420,6 +420,8 @@ function severityPrefix(severity: DoctorSeverity): string { return "[WARN]"; case "fail": return "[FAIL]"; + default: + return severity satisfies never; } } @@ -438,7 +440,7 @@ export function formatDoctorReport(report: DoctorReport): string[] { } } const failCount = report.findings.filter((finding) => finding.severity === "fail").length; - lines.push("", report.ok ? "All checks passed." : `${failCount} check(s) failed.`); + lines.push("", report.ok ? "All checks passed." : `${String(failCount)} check(s) failed.`); return lines; } diff --git a/src/identityManager.test.ts b/src/identityManager.test.ts index 0476edc..44cf41e 100644 --- a/src/identityManager.test.ts +++ b/src/identityManager.test.ts @@ -30,14 +30,14 @@ function scriptedIdentityPrompts(answers: readonly unknown[]): PromptsPort { return value; }; return { - select: (params: SelectParams): Promise => { + select: async (params: SelectParams): Promise => { const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); const option = params.options.find((o) => o.value === answer); if (option === undefined) throw new Error(`scripted select answer not in options: ${String(answer)}`); return Promise.resolve(option.value); }, - multiselect: (params: MultiselectParams): Promise => { + multiselect: async (params: MultiselectParams): Promise => { const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); if (!Array.isArray(answer)) throw new Error(`scripted multiselect answer is not an array: ${String(answer)}`); @@ -49,7 +49,7 @@ function scriptedIdentityPrompts(answers: readonly unknown[]): PromptsPort { } return Promise.resolve(selected); }, - text: (): Promise => { + text: async (): Promise => { const answer = next(); if (typeof answer === "symbol") return Promise.resolve(answer); if (typeof answer !== "string") throw new Error(`scripted text answer is not a string: ${String(answer)}`); @@ -110,7 +110,7 @@ describe("identityManager", () => { describe("useIdentity / readActiveIdentity", () => { it("throws IdentityNotFoundError when selecting an identity that was never created", () => { - expect(() => useIdentity(paths, "ghost")).toThrow(IdentityNotFoundError); + expect(() => { useIdentity(paths, "ghost"); }).toThrow(IdentityNotFoundError); }); it("persists the active identity as plain trimmed text", () => { @@ -224,7 +224,7 @@ describe("identityManager", () => { const base = scriptedIdentityPrompts(["create", "skip"]); const spying: PromptsPort = { ...base, - select: (params) => { + select: async (params) => { selectCalls.push(params); return base.select(params); }, diff --git a/src/identityManager.ts b/src/identityManager.ts index 8b63c1e..82adfd5 100644 --- a/src/identityManager.ts +++ b/src/identityManager.ts @@ -323,7 +323,7 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v if (result.autoResolved.length > 0) { console.log( - `Auto-resolved ${result.autoResolved.length} disposable runtime entr${result.autoResolved.length === 1 ? "y" : "ies"} ` + + `Auto-resolved ${String(result.autoResolved.length)} disposable runtime entr${result.autoResolved.length === 1 ? "y" : "ies"} ` + `with no prompt (${result.autoResolved.join(", ")}) — per-process/per-machine state, never worth asking about.`, ); } @@ -337,8 +337,8 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v console.log(` ${conflict.name}: ${conflict.choice}`); } console.log( - `Resolved ${result.resolved.length} conflict(s) — ${result.removed.length} superseded director(ies) fully ` + - `cleared, ${result.retained.length} still retained pending a skipped conflict.`, + `Resolved ${String(result.resolved.length)} conflict(s) — ${String(result.removed.length)} superseded director(ies) fully ` + + `cleared, ${String(result.retained.length)} still retained pending a skipped conflict.`, ); }); @@ -400,7 +400,7 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v .description("Update an identity's own settings.") .option("--allow-ambient-credential", "Allow this identity to launch even with an ambient credential env var set.") .option("--no-allow-ambient-credential", "Disallow ambient credential env vars for this identity (the default).") - .action((name: string, options: { allowAmbientCredential?: boolean }) => { + .action((name: string, options: Readonly<{ allowAmbientCredential?: boolean }>) => { if (options.allowAmbientCredential === undefined) { console.log("Nothing to change: pass --allow-ambient-credential or --no-allow-ambient-credential."); return; diff --git a/src/launcher.test.ts b/src/launcher.test.ts index 3f3b6ef..6a83f68 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -10,13 +10,13 @@ import type { DiscoveredClaudeBinary } from "./versionDiscovery"; class ExitCalled extends Error { constructor(readonly code: number) { - super(`process would exit with code ${code}`); + super(`process would exit with code ${String(code)}`); } } const paths = buildLayoutPaths("/home/testuser/.claude-use"); -function fakeProc(env: Record, argv: string[]): ProcPort { +function fakeProc(env: Readonly>, argv: readonly string[]): ProcPort { return { env, argv, @@ -47,9 +47,9 @@ function fakeLog(): LogPort & { infos: string[]; warns: string[]; errors: string infos, warns, errors, - info: (message) => infos.push(message), - warn: (message) => warns.push(message), - error: (message) => errors.push(message), + info: (message) => { infos.push(message); }, + warn: (message) => { warns.push(message); }, + error: (message) => { errors.push(message); }, }; } diff --git a/src/launcher.ts b/src/launcher.ts index d749d2e..871e705 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -67,7 +67,7 @@ export interface RunLauncherParams { /** * Orchestrates one `claude` launch, in order: * - * `CLAUDE_CONFIG_DIR` escape-hatch check -> ambient-credential guard -> identity/config-profile decision -> farm resync -> version discovery -> flag resolution -> extra-flags split -> spawn. + * `CLAUDE_CONFIG_DIR` escape-hatch check, then the ambient-credential guard, the identity/config-profile decision, farm resync, version discovery, flag resolution, the extra-flags split, and finally spawn. * * The farm resync is skipped when `CLAUDE_CONFIG_DIR` was already set (the escape hatch means the user has named a configuration directory explicitly, and claude-use manages neither its contents nor its lifetime) and when no identity resolved at all (a bare launch against plain `~/.claude`, matching the legacy tool's own behaviour). In both cases there is no claude-use-managed farm for a resync to act on. */ @@ -117,7 +117,7 @@ export function runLauncher(params: RunLauncherParams): void { } catch (error) { if (error instanceof IdentityLockBusyError) { log.error(error.message); - return proc.exit(1); + proc.exit(1); } throw error; } @@ -146,7 +146,7 @@ export function runLauncher(params: RunLauncherParams): void { }); if (!guardResult.ok) { log.error(guardResult.message); - return proc.exit(1); + proc.exit(1); } const configProfileDecision = decideConfigProfile({ @@ -187,7 +187,7 @@ export function runLauncher(params: RunLauncherParams): void { } catch (error) { if (error instanceof IdentityLockBusyError) { log.error(error.message); - return proc.exit(1); + proc.exit(1); } throw error; } @@ -202,8 +202,8 @@ export function runLauncher(params: RunLauncherParams): void { log.info( result.noOp ? `claude-use: farm at ${result.farmRoot} already matches the resolved cascade` - : `claude-use: farm at ${result.farmRoot} resynced (${result.manifest.links.length} link(s), ` + - `${result.manifest.materialised.length} built director(ies)${result.adopted.length === 0 ? "" : `, ${result.adopted.length} adopted into ${farm.claudeHome}`})`, + : `claude-use: farm at ${result.farmRoot} resynced (${String(result.manifest.links.length)} link(s), ` + + `${String(result.manifest.materialised.length)} built director(ies)${result.adopted.length === 0 ? "" : `, ${String(result.adopted.length)} adopted into ${farm.claudeHome}`})`, ); cascadeLaunch = result.resolved.flattened.launch; } diff --git a/src/launcher/argv.ts b/src/launcher/argv.ts index cb9a086..9f0d73c 100644 --- a/src/launcher/argv.ts +++ b/src/launcher/argv.ts @@ -46,7 +46,10 @@ export function parseLauncherArgv(argv: readonly string[]): ParsedLauncherArgv { const rest: string[] = []; for (let index = 0; index < remaining.length; index += 1) { - const token = remaining[index]!; + const token = remaining[index]; + if (token === undefined) { + continue; + } const matched = matchValuedFlag(token); if (matched === undefined) { rest.push(token); diff --git a/src/launcher/cliOverride.ts b/src/launcher/cliOverride.ts index 11593f8..3e7ecd2 100644 --- a/src/launcher/cliOverride.ts +++ b/src/launcher/cliOverride.ts @@ -18,7 +18,7 @@ export class InvalidCliEntryKeyError extends CliError { } } -function toCategoryMap(pairs: Record): CategoryMap { +function toCategoryMap(pairs: Readonly>): CategoryMap { const expanded = expandAllCategoryKey(pairs); const result: Record = {}; for (const [key, value] of Object.entries(expanded)) { @@ -30,7 +30,7 @@ function toCategoryMap(pairs: Record): CategoryMap { return result; } -function toEntries(pairs: Record): Entries { +function toEntries(pairs: Readonly>): Entries { for (const key of Object.keys(pairs)) { if (!ENTRY_KEY_RE.test(key)) { throw new InvalidCliEntryKeyError(key); diff --git a/src/launcher/farm.test.ts b/src/launcher/farm.test.ts index 540d522..58c5470 100644 --- a/src/launcher/farm.test.ts +++ b/src/launcher/farm.test.ts @@ -161,7 +161,7 @@ describe("resyncFarm", () => { const result = resyncFarm(params(fs, { uniqueSuffix: "second", cascade: split })); expect(fs.readFileUtf8(`${FAKE_CLAUDE_HOME}/skills/review/SKILL.md`)).toBe("review skill"); - expect(fs.readFileUtf8(`${FAKE_CLAUDE_HOME}/skills/review/SKILL.md.farm-conflict-${FAKE_NOW_MS}`)).toBe("diverged in the farm"); + expect(fs.readFileUtf8(`${FAKE_CLAUDE_HOME}/skills/review/SKILL.md.farm-conflict-${String(FAKE_NOW_MS)}`)).toBe("diverged in the farm"); expect(result.diagnostics.some((diagnostic) => diagnostic.code === "RECONCILE_CONFLICT")).toBe(true); }); diff --git a/src/launcher/farm.ts b/src/launcher/farm.ts index c8f80f0..02afcf2 100644 --- a/src/launcher/farm.ts +++ b/src/launcher/farm.ts @@ -114,25 +114,22 @@ export function buildEntryFacts(params: BuildEntryFactsParams): EntryFacts { }; } -function listSubtree(fs: FarmFs, root: string, rel: string, out: ListingEntry[]): void { +// A pure recursive listing, returning each level's entries rather than mutating a shared out-parameter (the latter was this function's original shape, but that requires a non-readonly array parameter -- exadev/prefer-readonly-array-param wants every array parameter readonly unconditionally, forcing exactly this kind of in-place-mutation pattern to be reconsidered rather than merely annotated around. This also fixed a genuine pre-existing bug: the old out-parameter was typed readonly ListingEntry[] while every caller passed a real mutable array for it to push into -- a real TS2339 tsc --noEmit was masking behind a stale tsconfig.tsbuildinfo from incremental compilation). +function listSubtree(fs: FarmFs, root: string, rel: string): ListingEntry[] { const absolute = path.join(root, rel); const stat = fs.lstat(absolute); if (stat === undefined) { - return; + return []; } if (stat.kind === "symlink") { - out.push({ rel, kind: "symlink" }); - return; + return [{ rel, kind: "symlink" }]; } if (stat.kind === "dir") { - out.push({ rel, kind: "dir" }); - for (const name of [...fs.readdir(absolute)].sort()) { - listSubtree(fs, root, `${rel}/${name}`, out); - } - return; + const children = [...fs.readdir(absolute)].sort().flatMap((name) => listSubtree(fs, root, `${rel}/${name}`)); + return [{ rel, kind: "dir" }, ...children]; } const contentHash = fs.hashFile(absolute); - out.push({ rel, kind: "file", ...(contentHash === undefined ? {} : { contentHash }) }); + return [{ rel, kind: "file", ...(contentHash === undefined ? {} : { contentHash }) }]; } /** Drops any scope root that already sits beneath another, so a nested materialised directory is not walked twice. */ @@ -166,11 +163,7 @@ function reconciliationScope(fs: FarmFs, farmRoot: string, manifest: FarmManifes /** Lists the old farm's reconcilable subtrees, flat and hashed, ready for `planReconciliation`. */ function collectFarmListing(fs: FarmFs, farmRoot: string, scopeRoots: readonly string[]): ListingEntry[] { - const out: ListingEntry[] = []; - for (const root of scopeRoots) { - listSubtree(fs, farmRoot, root, out); - } - return out; + return scopeRoots.flatMap((root) => listSubtree(fs, farmRoot, root)); } /** @@ -558,7 +551,7 @@ function executeReconciliation(params: ExecuteReconciliationParams): Reconciliat continue; } - const preserved = `${canonical}.farm-conflict-${params.nowMs}`; + const preserved = `${canonical}.farm-conflict-${String(params.nowMs)}`; params.fs.mkdirp(path.dirname(canonical)); params.fs.copyRecursive(from, preserved); conflicts.push(action.rel); @@ -619,7 +612,10 @@ function sameLinks(a: FarmManifest["links"], b: FarmManifest["links"]): boolean } return a.every((link, index) => { const other = b[index]; - return other?.rel === link.rel && other?.target === link.target; + if (other === undefined) { + return false; + } + return other.rel === link.rel && other.target === link.target; }); } @@ -794,16 +790,16 @@ export function recoveryDiagnostics(recovery: RecoveryResult, identity: string): } const parts: string[] = []; if (recovery.removedScratch.length > 0) { - parts.push(`removed ${recovery.removedScratch.length} abandoned scratch tree(s)`); + parts.push(`removed ${String(recovery.removedScratch.length)} abandoned scratch tree(s)`); } if (recovery.restoredFrom !== undefined) { parts.push(`restored the farm from ${recovery.restoredFrom}, which a previous launch was killed mid-swap`); } if (recovery.completed.length > 0) { - parts.push(`finished carrying local state out of ${recovery.completed.length} superseded farm(s)`); + parts.push(`finished carrying local state out of ${String(recovery.completed.length)} superseded farm(s)`); } if (recovery.autoResolved.length > 0) { - parts.push(`discarded ${recovery.autoResolved.length} superseded runtime entr${recovery.autoResolved.length === 1 ? "y" : "ies"} (${recovery.autoResolved.join(", ")}) — disposable per-machine state, safe to drop without asking`); + parts.push(`discarded ${String(recovery.autoResolved.length)} superseded runtime entr${recovery.autoResolved.length === 1 ? "y" : "ies"} (${recovery.autoResolved.join(", ")}) — disposable per-machine state, safe to drop without asking`); } if (recovery.retained.length > 0) { parts.push( diff --git a/src/launcher/farmResolve.test.ts b/src/launcher/farmResolve.test.ts index eb6334e..deee9f8 100644 --- a/src/launcher/farmResolve.test.ts +++ b/src/launcher/farmResolve.test.ts @@ -9,7 +9,7 @@ const PREVIOUS = `${IDENTITIES_DIR}/.work.previous.crashed`; /** Always answers with the same fixed choice, regardless of which conflict is asked about. */ function fixedAnswer(choice: FarmConflictChoice): (conflict: FarmConflict) => Promise { - return () => Promise.resolve(choice); + return async () => Promise.resolve(choice); } describe("resolveFarmConflicts", () => { @@ -119,7 +119,7 @@ describe("resolveFarmConflicts", () => { fs, identitiesDir: IDENTITIES_DIR, identity: "work", - decide: (conflict) => { + decide: async (conflict) => { seen.push(conflict.previousRoot); return Promise.resolve("keep-new"); }, @@ -143,7 +143,7 @@ describe("resolveFarmConflicts", () => { fs, identitiesDir: IDENTITIES_DIR, identity: "work", - decide: (conflict) => Promise.resolve(conflict.previousRoot === previousA ? "skip" : "keep-new"), + decide: async (conflict) => Promise.resolve(conflict.previousRoot === previousA ? "skip" : "keep-new"), }); expect(result.removed).toEqual([previousB]); @@ -162,7 +162,7 @@ describe("resolveFarmConflicts", () => { identitiesDir: IDENTITIES_DIR, identity: "work", classification: { defaults: shippedClassification }, - decide: () => { + decide: async () => { decideCalls += 1; return Promise.resolve("skip"); }, @@ -190,7 +190,7 @@ describe("resolveFarmConflicts", () => { identitiesDir: IDENTITIES_DIR, identity: "work", classification: { defaults: shippedClassification }, - decide: (conflict) => { + decide: async (conflict) => { seen.push(conflict.name); return Promise.resolve("skip"); }, diff --git a/src/launcher/lock.test.ts b/src/launcher/lock.test.ts index 0d69db8..405f984 100644 --- a/src/launcher/lock.test.ts +++ b/src/launcher/lock.test.ts @@ -5,9 +5,25 @@ import { acquireIdentityLock, identityLockPath, IdentityLockBusyError } from "./ const IDENTITIES_DIR = "/home/testuser/.claude-use/identities"; +// A fixed sequence of fixture timestamps (ms), each test picking whichever of these represents "when this call happens" relative to the others. +const T0 = 1_000; +const T0_PLUS_100_MS = 1_100; +const T0_PLUS_200_MS = 1_200; +const T0_PLUS_1_MS = 1_001; +// Comfortably past acquireIdentityLock's own staleness window, so a lock acquired at T0 reads as stale by this time regardless of its holder's liveness. +const PAST_STALENESS_WINDOW_MS = 200_000; + +const RETRY_DELAY_MS = 5; +const MAX_ATTEMPTS = 3; + +// Fixture PIDs -- the exact values carry no meaning beyond "a" vs "a different" process; PID_HOLDER_VERBOSE is deliberately a different digit count so the "names the blocking process" test can assert on it unambiguously. +const PID_HOLDER = 42; +const PID_WAITER = 43; +const PID_HOLDER_VERBOSE = 4242; + function fakeSleep(): { sleep: (ms: number) => void; calls: number[] } { const calls: number[] = []; - return { sleep: (ms: number) => calls.push(ms), calls }; + return { sleep: (ms: number) => { calls.push(ms); }, calls }; } describe("acquireIdentityLock", () => { @@ -19,15 +35,15 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 42, + nowMs: () => T0, + pid: PID_HOLDER, isProcessAlive: () => true, sleep: sleeper.sleep, }); expect(lock.path).toBe(identityLockPath(IDENTITIES_DIR, "work")); const record: unknown = JSON.parse(fs.readFileUtf8(lock.path) ?? "null"); - expect(record).toMatchObject({ identity: "work", pid: 42, acquiredAtMs: 1_000 }); + expect(record).toMatchObject({ identity: "work", pid: PID_HOLDER, acquiredAtMs: T0 }); expect(sleeper.calls).toHaveLength(0); lock.release(); @@ -41,8 +57,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 42, + nowMs: () => T0, + pid: PID_HOLDER, isProcessAlive: () => true, sleep: sleeper.sleep, }); @@ -52,15 +68,15 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_100, - pid: 43, + nowMs: () => T0_PLUS_100_MS, + pid: PID_WAITER, isProcessAlive: () => true, sleep: sleeper.sleep, - maxAttempts: 3, - retryDelayMs: 5, + maxAttempts: MAX_ATTEMPTS, + retryDelayMs: RETRY_DELAY_MS, }), ).toThrow(IdentityLockBusyError); - expect(sleeper.calls).toEqual([5, 5, 5]); + expect(sleeper.calls).toEqual([RETRY_DELAY_MS, RETRY_DELAY_MS, RETRY_DELAY_MS]); // Serialisation, not exclusion: once the holder is done, the same waiter succeeds. held.release(); @@ -68,13 +84,13 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_200, - pid: 43, + nowMs: () => T0_PLUS_200_MS, + pid: PID_WAITER, isProcessAlive: () => true, sleep: sleeper.sleep, - maxAttempts: 3, + maxAttempts: MAX_ATTEMPTS, }); - expect(JSON.parse(fs.readFileUtf8(second.path) ?? "null")).toMatchObject({ pid: 43 }); + expect(JSON.parse(fs.readFileUtf8(second.path) ?? "null")).toMatchObject({ pid: PID_WAITER }); }); it("names the blocking process in the error so a wedged lock is diagnosable", () => { @@ -83,8 +99,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 4242, + nowMs: () => T0, + pid: PID_HOLDER_VERBOSE, isProcessAlive: () => true, sleep: fakeSleep().sleep, }); @@ -94,8 +110,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 43, + nowMs: () => T0, + pid: PID_WAITER, isProcessAlive: () => true, sleep: fakeSleep().sleep, maxAttempts: 1, @@ -109,8 +125,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 42, + nowMs: () => T0, + pid: PID_HOLDER, isProcessAlive: () => true, sleep: fakeSleep().sleep, }); @@ -120,14 +136,14 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_001, - pid: 43, - isProcessAlive: (pid) => pid === 43, + nowMs: () => T0_PLUS_1_MS, + pid: PID_WAITER, + isProcessAlive: (pid) => pid === PID_WAITER, sleep: sleeper.sleep, - maxAttempts: 3, + maxAttempts: MAX_ATTEMPTS, }); - expect(JSON.parse(fs.readFileUtf8(stolen.path) ?? "null")).toMatchObject({ pid: 43 }); + expect(JSON.parse(fs.readFileUtf8(stolen.path) ?? "null")).toMatchObject({ pid: PID_WAITER }); expect(sleeper.calls).toHaveLength(0); }); @@ -137,8 +153,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 42, + nowMs: () => T0, + pid: PID_HOLDER, isProcessAlive: () => true, sleep: fakeSleep().sleep, }); @@ -147,14 +163,14 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000 + 200_000, - pid: 43, + nowMs: () => T0 + PAST_STALENESS_WINDOW_MS, + pid: PID_WAITER, isProcessAlive: () => true, sleep: fakeSleep().sleep, - maxAttempts: 3, + maxAttempts: MAX_ATTEMPTS, }); - expect(JSON.parse(fs.readFileUtf8(stolen.path) ?? "null")).toMatchObject({ pid: 43 }); + expect(JSON.parse(fs.readFileUtf8(stolen.path) ?? "null")).toMatchObject({ pid: PID_WAITER }); }); it("steals a lock whose contents are unparseable, rather than waiting out a truncated write", () => { @@ -166,14 +182,14 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 43, + nowMs: () => T0, + pid: PID_WAITER, isProcessAlive: () => true, sleep: fakeSleep().sleep, - maxAttempts: 3, + maxAttempts: MAX_ATTEMPTS, }); - expect(JSON.parse(fs.readFileUtf8(lock.path) ?? "null")).toMatchObject({ pid: 43 }); + expect(JSON.parse(fs.readFileUtf8(lock.path) ?? "null")).toMatchObject({ pid: PID_WAITER }); }); it("does not release a lock another process has since taken", () => { @@ -182,8 +198,8 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_000, - pid: 42, + nowMs: () => T0, + pid: PID_HOLDER, isProcessAlive: () => true, sleep: fakeSleep().sleep, }); @@ -193,13 +209,13 @@ describe("acquireIdentityLock", () => { identity: "work", dir: IDENTITIES_DIR, fs, - nowMs: () => 1_001, - pid: 43, - isProcessAlive: (pid) => pid === 43, + nowMs: () => T0_PLUS_1_MS, + pid: PID_WAITER, + isProcessAlive: (pid) => pid === PID_WAITER, sleep: fakeSleep().sleep, }); first.release(); - expect(JSON.parse(fs.readFileUtf8(second.path) ?? "null")).toMatchObject({ pid: 43, token: second.token }); + expect(JSON.parse(fs.readFileUtf8(second.path) ?? "null")).toMatchObject({ pid: PID_WAITER, token: second.token }); }); }); diff --git a/src/launcher/lock.ts b/src/launcher/lock.ts index 2b1be0f..ad76790 100644 --- a/src/launcher/lock.ts +++ b/src/launcher/lock.ts @@ -35,7 +35,7 @@ export class IdentityLockBusyError extends CliError { ) { super( `Another claude-use resync is already running for identity "${identity}"` + - (holderPid === undefined ? "" : ` (pid ${holderPid})`) + + (holderPid === undefined ? "" : ` (pid ${String(holderPid)})`) + `. Its lock at ${lockPath} was still held after the full retry budget; nothing was changed.`, ); this.name = "IdentityLockBusyError"; diff --git a/src/launcher/spawn.test.ts b/src/launcher/spawn.test.ts index 495711d..3bfa3a1 100644 --- a/src/launcher/spawn.test.ts +++ b/src/launcher/spawn.test.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import { describe, expect, it, vi } from "vitest"; import type { ProcPort, SpawnPort, SpawnResult } from "./ports"; @@ -5,7 +6,7 @@ import { spawnClaude } from "./spawn"; class ExitCalled extends Error { constructor(readonly code: number) { - super(`process would exit with code ${code}`); + super(`process would exit with code ${String(code)}`); } } @@ -48,21 +49,24 @@ describe("spawnClaude", () => { }); it("propagates a non-zero exit code faithfully", () => { - const spawn = fakeSpawn({ status: 7, signal: null }); + const ARBITRARY_NONZERO_EXIT_CODE = 7; + const spawn = fakeSpawn({ status: ARBITRARY_NONZERO_EXIT_CODE, signal: null }); const proc = fakeProc(); const code = expectExitCode(() => spawnClaude({ bin: "/bin/claude", args: [], env: {}, spawn, proc }), ); - expect(code).toBe(7); + expect(code).toBe(ARBITRARY_NONZERO_EXIT_CODE); }); it("maps a signal-terminated child to 128 + signal number", () => { + const SIGNAL_EXIT_CODE_OFFSET = 128; + const SIGTERM_NUMBER = os.constants.signals.SIGTERM; const spawn = fakeSpawn({ status: null, signal: "SIGTERM" }); const proc = fakeProc(); const code = expectExitCode(() => spawnClaude({ bin: "/bin/claude", args: [], env: {}, spawn, proc }), ); - expect(code).toBe(143); // 128 + 15 (SIGTERM) + expect(code).toBe(SIGNAL_EXIT_CODE_OFFSET + SIGTERM_NUMBER); }); it("throws when the child could not even be spawned, instead of exiting cleanly", () => { diff --git a/src/launcher/spawn.ts b/src/launcher/spawn.ts index a4bb5d3..ef77dd3 100644 --- a/src/launcher/spawn.ts +++ b/src/launcher/spawn.ts @@ -12,16 +12,18 @@ export interface SpawnClaudeParams { readonly proc: ProcPort; } +// The conventional shell exit-code offset for a signal-terminated process (matching what a real shell's `exec` would report): 128 plus the signal's own number. +const SIGNAL_EXIT_CODE_OFFSET = 128; + /** - * Derives the exit code to propagate from a completed `spawnSync` result: the child's own exit status when it exited normally, or the conventional `128 + signal number` when it was terminated by a signal (matching what a real shell's `exec` would report), or `1` as a last resort when the result carries neither. + * Derives the exit code to propagate from a completed `spawnSync` result: the child's own exit status when it exited normally, or the conventional `128 + signal number` when it was terminated by a signal, or `1` as a last resort when the result carries neither. `os.constants.signals` is a closed mapping over every `NodeJS.Signals` name to its numeric value, so indexing it with a non-null `result.signal` is always defined -- confirmed directly, not merely assumed, since the earlier defensive `undefined` fallback here was itself flagged as unreachable. */ function exitCodeFor(result: SpawnResult): number { if (result.status !== null) { return result.status; } if (result.signal !== null) { - const signalNumber = os.constants.signals[result.signal]; - return signalNumber === undefined ? 1 : 128 + signalNumber; + return SIGNAL_EXIT_CODE_OFFSET + os.constants.signals[result.signal]; } return 1; } diff --git a/src/paths.test.ts b/src/paths.test.ts index 40fa475..4b4bbaa 100644 --- a/src/paths.test.ts +++ b/src/paths.test.ts @@ -66,11 +66,13 @@ describe("resolveLayoutPaths", () => { it("resolves every path under the test-scoped CLAUDE_USE_HOME root", () => { const layout = resolveLayoutPaths(); const home = process.env.CLAUDE_USE_HOME; - expect(home).toBeDefined(); + if (home === undefined) { + throw new Error("Expected CLAUDE_USE_HOME to be set by vitest.config.ts's test-scoped setup."); + } expect(layout.root).toBe(home); for (const value of layoutPathValues(layout)) { - expect(path.resolve(value).startsWith(path.resolve(home!))).toBe(true); + expect(path.resolve(value).startsWith(path.resolve(home))).toBe(true); } }); }); diff --git a/src/realPorts.test.ts b/src/realPorts.test.ts index 5838a3c..fea7372 100644 --- a/src/realPorts.test.ts +++ b/src/realPorts.test.ts @@ -21,9 +21,16 @@ describe("resolveContentSourcePath", () => { }); }); +// A regular file with no execute bit set (owner/group/other all read-write only). +const NON_EXECUTABLE_MODE = 0o644; +// A regular file with the owner's execute bit set, alongside group/other read+execute. +const EXECUTABLE_MODE = 0o755; +// Read-write for owner/group/other, deliberately with no execute bit at all -- used only in the Windows-branch tests below, where mode bits must be irrelevant to the result. +const MODE_WITHOUT_EXECUTE_BITS = 0o666; + describe("resolveExecutableCandidate", () => { it("on POSIX, requires the execute mode bits to be set", () => { - const modes = new Map([["/usr/bin/claude-use", 0o644]]); // regular file, not executable + const modes = new Map([["/usr/bin/claude-use", NON_EXECUTABLE_MODE]]); // regular file, not executable const result = resolveExecutableCandidate("/usr/bin", "claude-use", { platform: "linux", pathext: undefined, @@ -33,7 +40,7 @@ describe("resolveExecutableCandidate", () => { }); it("on POSIX, finds a file whose execute bits are set", () => { - const modes = new Map([["/usr/bin/claude-use", 0o755]]); + const modes = new Map([["/usr/bin/claude-use", EXECUTABLE_MODE]]); const result = resolveExecutableCandidate("/usr/bin", "claude-use", { platform: "linux", pathext: undefined, @@ -46,7 +53,7 @@ describe("resolveExecutableCandidate", () => { it("on Windows, mode bits are irrelevant -- a plain .exe with no execute bits at all must still be found", () => { // This is the confirmed real bug: Node's own docs state fs.Stats.mode on Windows only ever exposes owner read/write, never execute -- a POSIX-style (mode & 0o111) check silently rejects every file on Windows, which is exactly why the Scoop shim redirect (a real, explicitly-named .exe) was never found in CI. - const modes = new Map([[path.join("/scoop/shims", "claude-use.exe"), 0o666]]); + const modes = new Map([[path.join("/scoop/shims", "claude-use.exe"), MODE_WITHOUT_EXECUTE_BITS]]); const result = resolveExecutableCandidate("/scoop/shims", "claude-use.exe", { platform: "win32", pathext: ".COM;.EXE;.BAT;.CMD", @@ -62,7 +69,7 @@ describe("resolveExecutableCandidate", () => { } it("on Windows, tries each PATHEXT extension in turn for a bare name with no extension", () => { - const statFileMode = caseInsensitiveModes([[`${path.join("/bin", "claude")}.exe`, 0o666]]); + const statFileMode = caseInsensitiveModes([[`${path.join("/bin", "claude")}.exe`, MODE_WITHOUT_EXECUTE_BITS]]); const result = resolveExecutableCandidate("/bin", "claude", { platform: "win32", pathext: ".COM;.EXE;.BAT;.CMD", @@ -73,8 +80,8 @@ describe("resolveExecutableCandidate", () => { it("on Windows, stops at the first PATHEXT extension that matches", () => { const statFileMode = caseInsensitiveModes([ - [`${path.join("/bin", "claude")}.bat`, 0o666], - [`${path.join("/bin", "claude")}.cmd`, 0o666], + [`${path.join("/bin", "claude")}.bat`, MODE_WITHOUT_EXECUTE_BITS], + [`${path.join("/bin", "claude")}.cmd`, MODE_WITHOUT_EXECUTE_BITS], ]); const result = resolveExecutableCandidate("/bin", "claude", { platform: "win32", @@ -85,7 +92,7 @@ describe("resolveExecutableCandidate", () => { }); it("on Windows, falls back to the documented default PATHEXT list when unset", () => { - const statFileMode = caseInsensitiveModes([[`${path.join("/bin", "claude")}.exe`, 0o666]]); + const statFileMode = caseInsensitiveModes([[`${path.join("/bin", "claude")}.exe`, MODE_WITHOUT_EXECUTE_BITS]]); const result = resolveExecutableCandidate("/bin", "claude", { platform: "win32", pathext: undefined, diff --git a/src/realPorts.ts b/src/realPorts.ts index b94e2d3..22fb0ac 100644 --- a/src/realPorts.ts +++ b/src/realPorts.ts @@ -116,13 +116,16 @@ export const realFarmFs: FarmFs = { }, }; +// A single Int32Array element, the minimum SharedArrayBuffer Atomics.wait can block on. +const INT32_BYTE_LENGTH = 4; + /** * Blocks the current thread for `ms` milliseconds. * * The launcher is synchronous end to end, right through to `spawnSync`, so waiting on another process's identity lock cannot be done with a promise. `Atomics.wait` on a private buffer is the one way to sleep synchronously without burning the CPU in a spin loop. */ export function realSleepSync(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + Atomics.wait(new Int32Array(new SharedArrayBuffer(INT32_BYTE_LENGTH)), 0, 0, ms); } /** Whether a process is still running. Signal 0 performs the permission and existence checks without delivering anything; `EPERM` means the process exists but belongs to another user. */ @@ -139,7 +142,7 @@ export function realIsProcessAlive(pid: number): boolean { export const realRunPort: RunPort = { run(command, args) { const result = spawnSync(command, [...args], { encoding: "utf8" }); - return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; }, }; @@ -176,11 +179,14 @@ export const realProcPort: ProcPort = { /** The real `LogPort`, writing info/warn to stdout and errors to stderr. */ export const realLogPort: LogPort = { - info: (message: string) => console.log(message), - warn: (message: string) => console.warn(message), - error: (message: string) => console.error(message), + info: (message: string) => { console.log(message); }, + warn: (message: string) => { console.warn(message); }, + error: (message: string) => { console.error(message); }, }; +// The owner/group/other execute bits (0b001 repeated in each of the three permission triads) -- a nonzero result means at least one of the three "may execute" bits is set. +const EXECUTE_BITS_MASK = 0o111; + function listVersionsDir(dir: string): VersionsDirEntry[] { let entries: fs.Dirent[]; try { @@ -199,7 +205,7 @@ function listVersionsDir(dir: string): VersionsDirEntry[] { return { name: entry.name, isFile: true, - isExecutable: (stat.mode & 0o111) !== 0, + isExecutable: (stat.mode & EXECUTE_BITS_MASK) !== 0, sizeBytes: stat.size, }; }); @@ -249,7 +255,7 @@ export function resolveExecutableCandidate( } const mode = env.statFileMode(candidate); - return mode !== undefined && (mode & 0o111) !== 0 ? candidate : undefined; + return mode !== undefined && (mode & EXECUTE_BITS_MASK) !== 0 ? candidate : undefined; } function realStatFileMode(candidate: string): number | undefined { @@ -276,10 +282,10 @@ export function findExecutableInDir(dir: string, name: string): string | undefin function searchPathForExecutable( pathDirs: readonly string[], name: string, - findExecutableInDir: (dir: string, name: string) => string | undefined, + findExecutable: (dir: string, name: string) => string | undefined, ): string | undefined { for (const dir of pathDirs) { - const found = findExecutableInDir(dir, name); + const found = findExecutable(dir, name); if (found !== undefined) { return found; } @@ -296,7 +302,7 @@ function searchPathForExecutable( * * The redirect-if-not-on-PATH check below applies uniformly to whichever candidate we end up with — whether derived from a path-shaped `argv1`, or from the `execPath` fallback (`argv1` undefined) — since it's unconfirmed whether a Windows SEA binary duplicates `argv[0]` into `argv[1]` the same way the POSIX build does; treating both sources identically is strictly more robust either way and regresses nothing already confirmed working. * - * The algorithm: if the raw candidate is a bare word with no path separator, it can only have been found via PATH lookup in the first place (Node/the OS applies no resolution to it at all), so search PATH ourselves for the first directory containing an executable of that name, reconstructing exactly what the shell already did. Otherwise, resolve it (or the `execPath` fallback) against cwd, then check whether the *resulting directory* is itself on PATH: if so, use it directly (the Homebrew/direct-invocation case — never realpath'd, since a package manager's PATH-visible entry is often a symlink elsewhere, e.g. Homebrew's `/opt/homebrew/bin/claude-use` -> its own Cellar keg, and callers need that PATH-visible location, not the dereferenced target). If that directory is *not* on PATH (the Scoop re-exec case above), search PATH for a separately-installed entry sharing our own invoked basename before falling back to the direct candidate. + * The algorithm: if the raw candidate is a bare word with no path separator, it can only have been found via PATH lookup in the first place (Node/the OS applies no resolution to it at all), so search PATH ourselves for the first directory containing an executable of that name, reconstructing exactly what the shell already did. Otherwise, resolve it (or the `execPath` fallback) against cwd, then check whether the *resulting directory* is itself on PATH: if so, use it directly (the Homebrew/direct-invocation case — never realpath'd, since a package manager's PATH-visible entry is often a symlink elsewhere, e.g. Homebrew's `/opt/homebrew/bin/claude-use` pointing at its own Cellar keg, and callers need that PATH-visible location, not the dereferenced target). If that directory is *not* on PATH (the Scoop re-exec case above), search PATH for a separately-installed entry sharing our own invoked basename before falling back to the direct candidate. */ export function resolveOwnExecutablePath(env: { readonly argv1: string | undefined; diff --git a/src/resolve/conditions.test.ts b/src/resolve/conditions.test.ts index c33fff5..bfa2990 100644 --- a/src/resolve/conditions.test.ts +++ b/src/resolve/conditions.test.ts @@ -6,6 +6,13 @@ import type { EntryFact } from "./types"; const baseContext: ConditionContext = { nowMs: FAKE_NOW_MS, env: {} }; +const HALF_SECOND_MS = 500; +const FORTY_FIVE_SECONDS_MS = 45_000; +const THIRTY_MINUTES_MS = 1_800_000; +const TWELVE_HOURS_MS = 43_200_000; +const DAYS_PER_WEEK = 7; +const NINETY = 90; + function fact(overrides: Partial): EntryFact { return { relPath: "projects/-a-b", @@ -21,12 +28,12 @@ function fact(overrides: Partial): EntryFact { describe("parseDuration", () => { it.each([ - ["500ms", 500], - ["45s", 45_000], - ["30m", 1_800_000], - ["12h", 43_200_000], - ["90d", 90 * DAY_MS], - ["2w", 2 * 7 * DAY_MS], + ["500ms", HALF_SECOND_MS], + ["45s", FORTY_FIVE_SECONDS_MS], + ["30m", THIRTY_MINUTES_MS], + ["12h", TWELVE_HOURS_MS], + ["90d", NINETY * DAY_MS], + ["2w", 2 * DAYS_PER_WEEK * DAY_MS], ["0d", 0], ])("parses %s as %d ms", (value, expected) => { expect(parseDuration(value)).toBe(expected); @@ -92,9 +99,12 @@ describe("evaluateWhen", () => { }); }); +// Comfortably outside the 90-day window every newerThan/olderThan test below checks against. +const STALE_AGE_DAYS = 200; + describe("newerThan and olderThan", () => { const fresh = fact({ latestMtimeMs: FAKE_NOW_MS - 1 * DAY_MS }); - const stale = fact({ latestMtimeMs: FAKE_NOW_MS - 200 * DAY_MS }); + const stale = fact({ latestMtimeMs: FAKE_NOW_MS - STALE_AGE_DAYS * DAY_MS }); it("includes a fresh entry and excludes a stale one under the same window", () => { expect(evaluateWhen({ newerThan: "90d" }, { ...baseContext, fact: fresh }).passed).toBe(true); @@ -110,15 +120,18 @@ describe("newerThan and olderThan", () => { }); }); +// Well outside the 90-day newerThan window the "reads the subtree" tests check against -- the whole point of the test is that this ancient directory-own mtime must not be what evaluateWhen sees. +const ANCIENT_DIR_MTIME_AGE_DAYS = 400; + describe("directory-scoped conditions read the subtree, not the directory's own inode", () => { it("uses the subtree's most recent mtime for newerThan", () => { // The directory's own mtime is ancient; a file three levels down was written today. A naive stat of the directory itself would wrongly conclude the whole subtree is stale. const facts = makeFacts({ - "projects/-a-b": { dir: true, mtimeMs: FAKE_NOW_MS - 400 * DAY_MS }, + "projects/-a-b": { dir: true, mtimeMs: FAKE_NOW_MS - ANCIENT_DIR_MTIME_AGE_DAYS * DAY_MS }, "projects/-a-b/nested/session.jsonl": { mtimeMs: FAKE_NOW_MS - 1 * DAY_MS, sizeBytes: 10 }, }); const directory = facts.entries.get("projects/-a-b"); - expect(directory?.mtimeMs).toBe(FAKE_NOW_MS - 400 * DAY_MS); + expect(directory?.mtimeMs).toBe(FAKE_NOW_MS - ANCIENT_DIR_MTIME_AGE_DAYS * DAY_MS); expect(directory?.latestMtimeMs).toBe(FAKE_NOW_MS - 1 * DAY_MS); expect(evaluateWhen({ newerThan: "90d" }, { ...baseContext, ...(directory === undefined ? {} : { fact: directory }) }).passed).toBe( true, @@ -126,14 +139,17 @@ describe("directory-scoped conditions read the subtree, not the directory's own }); it("uses the subtree's recursive total size for maxSizeBytes", () => { + const dirOwnSizeBytes = 4_096; + const fileOneSizeBytes = 5_000; + const fileTwoSizeBytes = 6_000; const facts = makeFacts({ - "projects/-a-b": { dir: true, sizeBytes: 4096 }, - "projects/-a-b/one.jsonl": { sizeBytes: 5_000 }, - "projects/-a-b/two.jsonl": { sizeBytes: 6_000 }, + "projects/-a-b": { dir: true, sizeBytes: dirOwnSizeBytes }, + "projects/-a-b/one.jsonl": { sizeBytes: fileOneSizeBytes }, + "projects/-a-b/two.jsonl": { sizeBytes: fileTwoSizeBytes }, }); const directory = facts.entries.get("projects/-a-b"); - expect(directory?.sizeBytes).toBe(4096); - expect(directory?.totalSizeBytes).toBe(15_096); + expect(directory?.sizeBytes).toBe(dirOwnSizeBytes); + expect(directory?.totalSizeBytes).toBe(dirOwnSizeBytes + fileOneSizeBytes + fileTwoSizeBytes); const context = { ...baseContext, ...(directory === undefined ? {} : { fact: directory }) }; expect(evaluateWhen({ maxSizeBytes: 10_000 }, context).passed).toBe(false); expect(evaluateWhen({ maxSizeBytes: 20_000 }, context).passed).toBe(true); diff --git a/src/resolve/conditions.ts b/src/resolve/conditions.ts index b07fe4a..4f20504 100644 --- a/src/resolve/conditions.ts +++ b/src/resolve/conditions.ts @@ -21,8 +21,11 @@ export function parseDuration(value: string): number { throw new Error(`"${value}" is not a valid duration. Expected a count followed by ms, s, m, h, d, or w.`); } const [, count, unit] = parts; - const multiplier = MILLISECONDS_PER_UNIT[unit!]; - if (count === undefined || multiplier === undefined) { + if (count === undefined || unit === undefined) { + throw new Error(`"${value}" is not a valid duration.`); + } + const multiplier = MILLISECONDS_PER_UNIT[unit]; + if (multiplier === undefined) { throw new Error(`"${value}" is not a valid duration.`); } return Number(count) * multiplier; diff --git a/src/resolve/decide.test.ts b/src/resolve/decide.test.ts index 31976ab..2ad9b0c 100644 --- a/src/resolve/decide.test.ts +++ b/src/resolve/decide.test.ts @@ -8,7 +8,7 @@ import { flattenLayers } from "./flatten"; import type { EntryFacts, Layer } from "./types"; function layer(id: number, overrides: Partial = {}): Layer { - return { id, kind: "config-profile", source: `layer-${id}`, ...overrides }; + return { id, kind: "config-profile", source: `layer-${String(id)}`, ...overrides }; } function classificationFor(facts: EntryFacts): ReadonlyMap { @@ -22,7 +22,7 @@ function classificationFor(facts: EntryFacts): ReadonlyMap { const facts = makeFacts({ "skills/commit/SKILL.md": true, "skills/other/SKILL.md": true }); it("lets a shallow layer's specific entry survive a later, deeper layer's blanket category flip", () => { + const DEEPER_LAYER_ID = 3; const layers = [ layer(0, { entries: { "knowledge/skills/commit": true } }), - layer(3, { categories: { knowledge: false } }), + layer(DEEPER_LAYER_ID, { categories: { knowledge: false } }), ]; expect(decide("skills/commit", layers, facts).decision.shared).toBe(true); expect(decide("skills/other", layers, facts).decision.shared).toBe(false); @@ -139,9 +140,12 @@ describe("the corrected comparator in practice", () => { }); }); +// Comfortably outside the 90-day-scale windows this describe block's conditions check against. +const STALE_AGE_DAYS = 200; + describe("failing conditions", () => { const facts = makeFacts({ - "projects/-home-testuser-work-acme/session.jsonl": { mtimeMs: FAKE_NOW_MS - 200 * DAY_MS, sizeBytes: 10 }, + "projects/-home-testuser-work-acme/session.jsonl": { mtimeMs: FAKE_NOW_MS - STALE_AGE_DAYS * DAY_MS, sizeBytes: 10 }, "projects/-home-testuser-work-fresh/session.jsonl": { mtimeMs: FAKE_NOW_MS - 1 * DAY_MS, sizeBytes: 10 }, }); @@ -198,7 +202,8 @@ describe("failing conditions", () => { describe("selectRule", () => { it("returns no rule and the full elimination list when every candidate's condition fails", () => { - const facts = makeFacts({ "skills/commit/SKILL.md": { mtimeMs: FAKE_NOW_MS - 400 * DAY_MS } }); + const ANCIENT_AGE_DAYS = 400; + const facts = makeFacts({ "skills/commit/SKILL.md": { mtimeMs: FAKE_NOW_MS - ANCIENT_AGE_DAYS * DAY_MS } }); const flattened = flattenLayers( [layer(0, { entries: { "knowledge/skills/*": { value: true, when: { newerThan: "1d" } } } })], { home: FAKE_HOME }, diff --git a/src/resolve/decide.ts b/src/resolve/decide.ts index e586d22..b5dbe22 100644 --- a/src/resolve/decide.ts +++ b/src/resolve/decide.ts @@ -175,8 +175,8 @@ function reportOverriddenExactKeys(winner: CompiledRule, params: DecideParams, r code: "EXACT_ENTRY_OVERRIDDEN_BY_LATER_GLOB", severity: "info", message: - `For "${relPath}", the exact key "${candidate.rawKey}" from layer ${candidate.layer} is overridden by the ` + - `glob "${winner.rawKey}" from the later layer ${winner.layer}. A later layer always wins, so a local rule ` + + `For "${relPath}", the exact key "${candidate.rawKey}" from layer ${String(candidate.layer)} is overridden by the ` + + `glob "${winner.rawKey}" from the later layer ${String(winner.layer)}. A later layer always wins, so a local rule ` + `can only ever tighten what an earlier, shared configuration opened.`, subject: relPath, layer: winner.layer, @@ -210,7 +210,7 @@ function dedupeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { const seen = new Set(); const unique: Diagnostic[] = []; for (const diagnostic of diagnostics) { - const key = `${diagnostic.code}${diagnostic.subject ?? ""}${diagnostic.layer ?? ""}${diagnostic.message}`; + const key = `${diagnostic.code} ${diagnostic.subject ?? ""} ${diagnostic.layer === undefined ? "" : String(diagnostic.layer)} ${diagnostic.message}`; if (seen.has(key)) { continue; } diff --git a/src/resolve/extends.test.ts b/src/resolve/extends.test.ts index fb6e6e4..8b85062 100644 --- a/src/resolve/extends.test.ts +++ b/src/resolve/extends.test.ts @@ -115,10 +115,11 @@ describe("missing profiles", () => { describe("profileLayers", () => { it("assigns strictly ascending layer ids starting from the given index", () => { + const startId = 5; const load = loader({ base: {}, work: { extends: ["base"] } }); - const { layers, nextId } = profileLayers("work", load, 5); - expect(layers.map((layer) => layer.id)).toEqual([5, 6]); - expect(nextId).toBe(7); + const { layers, nextId } = profileLayers("work", load, startId); + expect(layers.map((layer) => layer.id)).toEqual([startId, startId + 1]); + expect(nextId).toBe(startId + 2); }); it("carries each profile's categories, entries, entry order, and launch flags onto its layer", () => { diff --git a/src/resolve/flatten.test.ts b/src/resolve/flatten.test.ts index b801a2d..2a13573 100644 --- a/src/resolve/flatten.test.ts +++ b/src/resolve/flatten.test.ts @@ -5,7 +5,7 @@ import { flattenLayers, matchingRules } from "./flatten"; import type { Layer } from "./types"; function layer(id: number, overrides: Partial = {}): Layer { - return { id, kind: "config-profile", source: `layer-${id}`, ...overrides }; + return { id, kind: "config-profile", source: `layer-${String(id)}`, ...overrides }; } describe("phase one: categories", () => { @@ -48,13 +48,14 @@ describe("phase one: entries", () => { }); it("records each rule's own layer and ordinal, which is what lets phase two tell same-layer from cross-layer", () => { + const layerId = 3; const flattened = flattenLayers( - [layer(3, { entries: { "knowledge/skills/a": true, "knowledge/skills/b": false } })], + [layer(layerId, { entries: { "knowledge/skills/a": true, "knowledge/skills/b": false } })], { home: FAKE_HOME }, ); expect(flattened.rules.get("skills/a")?.ordinal).toBe(0); expect(flattened.rules.get("skills/b")?.ordinal).toBe(1); - expect(flattened.rules.get("skills/b")?.layer).toBe(3); + expect(flattened.rules.get("skills/b")?.layer).toBe(layerId); }); it("uses the explicitly captured entry order rather than whatever order the validated object happens to have", () => { diff --git a/src/resolve/flatten.ts b/src/resolve/flatten.ts index 706174d..1c849e7 100644 --- a/src/resolve/flatten.ts +++ b/src/resolve/flatten.ts @@ -17,7 +17,7 @@ function unpackEntryValue(value: EntryValue): { value: boolean; when?: CompiledR * * A key written under the `secret/` prefix is rejected outright with its own diagnostic and contributes no rule at all: `secret` is the one category no layer may open, and a deliberate attempt to name a secret path deserves a clearer error than the silent neutralisation the resolve-time floor check applies to a glob that reaches one incidentally. */ -export function flattenLayers(layers: readonly Layer[], options: { home: string }): FlattenedCascade { +export function flattenLayers(layers: readonly Layer[], options: Readonly<{ home: string }>): FlattenedCascade { const categories = new Map(); const rules = new Map(); const launch: { skipPermissions?: boolean; remoteControl?: boolean } = {}; @@ -26,9 +26,6 @@ export function flattenLayers(layers: readonly Layer[], options: { home: string for (const layer of layers) { if (layer.categories !== undefined) { for (const [name, value] of Object.entries(layer.categories)) { - if (value === undefined) { - continue; - } if (!isOverridableCategory(name)) { continue; } diff --git a/src/resolve/match.ts b/src/resolve/match.ts index a613735..da7d519 100644 --- a/src/resolve/match.ts +++ b/src/resolve/match.ts @@ -53,7 +53,7 @@ export class EntryKeyError extends CliError { * * Every key is `/`, so the category prefix is stripped and the remainder kept as written — with one deliberately narrow exception. Anything written after the literal `history/projects/` prefix is a real absolute working directory (optionally globbed), not a literal child directory name, because that directory's only real children are Claude Code's own encoded names and there is nothing else meaningful to reference there. Those fragments get `~`-expanded and forward-encoded; every other key in the whole design is a plain literal path or an ordinary glob over one, matched exactly as written. */ -export function canonicaliseEntryKey(key: string, options: { home: string }): CanonicalKey { +export function canonicaliseEntryKey(key: string, options: Readonly<{ home: string }>): CanonicalKey { const separator = key.indexOf("/"); if (separator <= 0) { throw new EntryKeyError(key, `Entry key "${key}" has no "/" prefix.`, "malformed"); diff --git a/src/resolve/pipeline.test.ts b/src/resolve/pipeline.test.ts index 1f4abf0..56af0d2 100644 --- a/src/resolve/pipeline.test.ts +++ b/src/resolve/pipeline.test.ts @@ -16,6 +16,9 @@ function loader(profiles: Readonly>): ProfileLoade }; } +// Comfortably outside the 90-day newerThan window the pipeline tests below check against. +const STALE_SESSION_AGE_DAYS = 200; + /** A realistic-shaped `~/.claude` fact manifest: knowledge, settings, history, runtime, and secrets side by side. */ function realisticFacts(overrides: Partial> = {}): EntryFacts { return makeFacts( @@ -29,7 +32,10 @@ function realisticFacts(overrides: Partial> = {}): E "settings.json": true, "shell-snapshots/snap.sh": true, "projects/-home-testuser-work-clients-acme/session.jsonl": { mtimeMs: FAKE_NOW_MS - 2 * DAY_MS, sizeBytes: 100 }, - "projects/-home-testuser-work-clients-widget/session.jsonl": { mtimeMs: FAKE_NOW_MS - 200 * DAY_MS, sizeBytes: 100 }, + "projects/-home-testuser-work-clients-widget/session.jsonl": { + mtimeMs: FAKE_NOW_MS - STALE_SESSION_AGE_DAYS * DAY_MS, + sizeBytes: 100, + }, }, overrides, ); diff --git a/src/resolve/plan.test.ts b/src/resolve/plan.test.ts index 60cba91..0e50633 100644 --- a/src/resolve/plan.test.ts +++ b/src/resolve/plan.test.ts @@ -9,7 +9,7 @@ import { buildChildIndex, planFarm, type FarmPlan } from "./plan"; import type { EntryFacts, Layer } from "./types"; function layer(id: number, overrides: Partial = {}): Layer { - return { id, kind: "config-profile", source: `layer-${id}`, ...overrides }; + return { id, kind: "config-profile", source: `layer-${String(id)}`, ...overrides }; } function classificationFor(facts: EntryFacts): ReadonlyMap { @@ -23,7 +23,7 @@ function classificationFor(facts: EntryFacts): ReadonlyMap { }); it("materialises even when the conditional rule's condition currently fails everywhere", () => { + // Well outside the 90-day newerThan window the rule checks against. + const ancientAgeDays = 400; const facts = makeFacts({ - "projects/-home-testuser-work-a/session.jsonl": { mtimeMs: FAKE_NOW_MS - 400 * DAY_MS }, + "projects/-home-testuser-work-a/session.jsonl": { mtimeMs: FAKE_NOW_MS - ancientAgeDays * DAY_MS }, }); const layers = [layer(0, { entries: { "history/projects/~/work/*": { value: true, when: { newerThan: "90d" } } } })]; expect(kindOf(plan(layers, facts), "projects")).toBe("materialise"); diff --git a/src/resolve/projects.ts b/src/resolve/projects.ts index 9d906eb..a6d5efc 100644 --- a/src/resolve/projects.ts +++ b/src/resolve/projects.ts @@ -37,7 +37,7 @@ export function splitOnWildcards(pattern: string): PatternFragment[] { while (index < pattern.length) { const token = WILDCARD_TOKENS.find((candidate) => pattern.startsWith(candidate, index)); if (token === undefined) { - literal += pattern[index]; + literal += pattern.charAt(index); index += 1; continue; } @@ -82,7 +82,7 @@ export function expandHome(fragment: string, home: string): string { * * Order matters and is fixed: expand `~` to the real home first (encoding `~` would turn it into `-` and lose the reference), reject anything not home-or-root-rooted, then split on wildcard tokens and encode only the literal runs. */ -export function encodeProjectPattern(fragment: string, options: { home: string }): string { +export function encodeProjectPattern(fragment: string, options: Readonly<{ home: string }>): string { const expanded = expandHome(fragment, options.home); if (!expanded.startsWith("/")) { throw new UnrootedProjectPathError(fragment); @@ -152,7 +152,8 @@ export function detectEncodingAmbiguity( const lossy = lossyCharacters(literalText); if (lossy.length > 0) { const matched = options.existingNames?.filter((name) => name === encoded).length; - const suffix = matched === undefined ? "" : ` It currently matches ${matched} existing project directory name(s).`; + const suffix = + matched === undefined ? "" : ` It currently matches ${String(matched)} existing project directory name(s).`; ambiguities.push({ fragment, encoded, diff --git a/src/resolve/walk.test.ts b/src/resolve/walk.test.ts index 4ac4e83..e386dde 100644 --- a/src/resolve/walk.test.ts +++ b/src/resolve/walk.test.ts @@ -74,7 +74,19 @@ describe("assembleCascade layer ordering", () => { "portable", "cli-override", ]); - expect(assembled.layers.map((layer) => layer.id)).toEqual([0, 1, 2, 3, 4]); + // Ordinal layer ids, assigned in cascade order: global config, the two-deep base-then-directory config-profile chain, the portable file, then the CLI override. + const globalLayerId = 0; + const baseProfileLayerId = 1; + const directoryProfileLayerId = 2; + const portableLayerId = 3; + const cliOverrideLayerId = 4; + expect(assembled.layers.map((layer) => layer.id)).toEqual([ + globalLayerId, + baseProfileLayerId, + directoryProfileLayerId, + portableLayerId, + cliOverrideLayerId, + ]); }); it("folds a level's three sources most-personal-last: committed file, then this user's rules, then the local override", () => { diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 2473098..ef89a62 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -21,8 +21,13 @@ export interface FakeEntrySpec { readonly sizeBytes?: number; } +const FAKE_NOW_YEAR = 2026; +const FAKE_NOW_MONTH_INDEX = 0; +const FAKE_NOW_DAY = 15; +const FAKE_NOW_HOUR = 12; + /** Fixed "now" for every test, so no assertion is time-dependent. */ -export const FAKE_NOW_MS = Date.UTC(2026, 0, 15, 12, 0, 0); +export const FAKE_NOW_MS = Date.UTC(FAKE_NOW_YEAR, FAKE_NOW_MONTH_INDEX, FAKE_NOW_DAY, FAKE_NOW_HOUR, 0, 0); /** Milliseconds in one day, for writing readable relative mtimes in fixtures. */ export const DAY_MS = 86_400_000; @@ -30,7 +35,12 @@ export const DAY_MS = 86_400_000; /** A fake `sleep(ms)` for lock/retry tests: never actually sleeps, but records every requested delay so a test can assert on backoff behaviour instead of being a bare no-op. */ export function fakeSleep(): { readonly sleep: (ms: number) => void; readonly delays: number[] } { const delays: number[] = []; - return { sleep: (ms: number) => delays.push(ms), delays }; + return { + sleep: (ms: number) => { + delays.push(ms); + }, + delays, + }; } function parentOf(rel: string): string { diff --git a/src/versionDiscovery.test.ts b/src/versionDiscovery.test.ts index 095643d..68b3fc1 100644 --- a/src/versionDiscovery.test.ts +++ b/src/versionDiscovery.test.ts @@ -7,7 +7,7 @@ import { type VersionsDirEntry, } from "./versionDiscovery"; -function file(name: string, opts: Partial> = {}): VersionsDirEntry { +function file(name: string, opts: Readonly>> = {}): VersionsDirEntry { return { name, isFile: true, diff --git a/src/versionDiscovery.ts b/src/versionDiscovery.ts index cd28369..c70e378 100644 --- a/src/versionDiscovery.ts +++ b/src/versionDiscovery.ts @@ -50,7 +50,7 @@ export function isNumericDottedVersion(name: string): boolean { } /** - * Compares two plain dotted-numeric version strings segment by segment, treating a missing trailing segment as 0 (so "2.1" < "2.1.1"). Returns a negative number when `a` < `b`, positive when `a` > `b`, and 0 when equal. Throws if either string isn't a valid numeric-dotted version — callers must filter with isNumericDottedVersion first, since a naive string/lexicographic sort would incorrectly rank "2.9.0" ahead of "2.10.0". + * Compares two plain dotted-numeric version strings segment by segment, treating a missing trailing segment as 0 (so "2.1" sorts before "2.1.1"). Returns a negative number when `a` is the earlier version, a positive number when `a` is the later one, and 0 when equal. Throws if either string isn't a valid numeric-dotted version — callers must filter with isNumericDottedVersion first, since a naive string/lexicographic sort would incorrectly rank "2.9.0" ahead of "2.10.0". */ export function compareVersions(a: string, b: string): number { if (!isNumericDottedVersion(a)) { @@ -77,7 +77,7 @@ export function compareVersions(a: string, b: string): number { /** * Filters `entries` to genuinely-executable, non-empty regular files whose name is a valid dotted-numeric version (skipping things like .DS_Store or a stray empty file), then picks the highest version by a real numeric-segment comparison. Returns undefined when nothing qualifies. */ -export function pickHighestVersion(entries: VersionsDirEntry[]): string | undefined { +export function pickHighestVersion(entries: readonly VersionsDirEntry[]): string | undefined { const candidates = entries.filter( (entry) => entry.isFile && entry.isExecutable && entry.sizeBytes > 0 && isNumericDottedVersion(entry.name), ); diff --git a/vitest.config.ts b/vitest.config.ts index 0d6c799..994eea3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; // Every test run gets its own throwaway CLAUDE_USE_HOME, well away from Joe's real, currently-in-daily-use identities at ~/.claude-use/active and ~/.claude-use/profiles/{mearman,exadev}/. src/test-setup.ts asserts this env var is set and does not resolve to the real ~/.claude-use before any test body runs, so no test in this project can ever touch real state. -const testClaudeUseHome = path.join(os.tmpdir(), `claude-use-test-${process.pid}-${Date.now()}`); +const testClaudeUseHome = path.join(os.tmpdir(), `claude-use-test-${String(process.pid)}-${String(Date.now())}`); export default defineConfig({ test: {