From e2898866b00394af90ac519a819b43d37511a3ec Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:33:25 -0700 Subject: [PATCH 1/6] Add scriptc-pm (spm), a source-augmenting pnpm wrapper (POC) spm runs pnpm, enumerates installed package instances from the isolated node_modules/.pnpm layout, locates each instance's upstream TypeScript (local override, tarball source or sourcesContent, npm provenance, registry gitHead, version tag), fetches the package directory at that commit (blobless sparse git, or the codeload archive with an in-process gzip + tar reader), verifies it against the published artifact (byte-exact sourcesContent, token-normalized re-emit equivalence, a structural check for bundles, and an exported-name surface check), and writes per-instance overlays plus .scriptc-pm/manifest.json (schema 1) and scriptc-sources.lock.json for scriptc --sources. It has no npm dependencies and compiles to a native binary with scriptc; scripts/smoke.sh checks that the Node and native lanes produce identical output on the public demo app in examples/spm-demo. Co-Authored-By: Claude Opus 5.5 --- examples/spm-demo/.gitignore | 4 + examples/spm-demo/package.json | 22 + examples/spm-demo/pnpm-lock.yaml | 353 +++++++++++++ examples/spm-demo/pnpm-workspace.yaml | 1 + examples/spm-demo/src/app.ts | 30 ++ examples/spm-demo/src/async-sema.ts | 16 + examples/spm-demo/src/fast-glob.ts | 4 + examples/spm-demo/src/openfeature.ts | 13 + examples/spm-demo/src/path-to-regexp.ts | 8 + examples/spm-demo/src/semver.ts | 3 + examples/spm-demo/src/smol-toml.ts | 11 + examples/spm-demo/src/ts-api-utils.ts | 3 + examples/spm-demo/src/typescript-estree.ts | 5 + examples/spm-demo/src/undici.ts | 4 + examples/spm-demo/tsconfig.json | 13 + packages/pm/.gitignore | 1 + packages/pm/package.json | 23 + packages/pm/scripts/smoke.sh | 54 ++ packages/pm/src/config.ts | 47 ++ packages/pm/src/fetch.ts | 254 ++++++++++ packages/pm/src/locate.ts | 177 +++++++ packages/pm/src/main.ts | 120 +++++ packages/pm/src/manifest.ts | 123 +++++ packages/pm/src/overlay.ts | 158 ++++++ packages/pm/src/pnpm.ts | 79 +++ packages/pm/src/published.ts | 199 ++++++++ packages/pm/src/registry.ts | 141 ++++++ packages/pm/src/repo.ts | 58 +++ packages/pm/src/status.ts | 82 +++ packages/pm/src/sync.ts | 546 ++++++++++++++++++++ packages/pm/src/tokens.ts | 502 ++++++++++++++++++ packages/pm/src/tree.ts | 209 ++++++++ packages/pm/src/util.ts | 212 ++++++++ packages/pm/src/verify.ts | 564 +++++++++++++++++++++ packages/pm/tsconfig.json | 13 + pnpm-lock.yaml | 2 + 36 files changed, 4054 insertions(+) create mode 100644 examples/spm-demo/.gitignore create mode 100644 examples/spm-demo/package.json create mode 100644 examples/spm-demo/pnpm-lock.yaml create mode 100644 examples/spm-demo/pnpm-workspace.yaml create mode 100644 examples/spm-demo/src/app.ts create mode 100644 examples/spm-demo/src/async-sema.ts create mode 100644 examples/spm-demo/src/fast-glob.ts create mode 100644 examples/spm-demo/src/openfeature.ts create mode 100644 examples/spm-demo/src/path-to-regexp.ts create mode 100644 examples/spm-demo/src/semver.ts create mode 100644 examples/spm-demo/src/smol-toml.ts create mode 100644 examples/spm-demo/src/ts-api-utils.ts create mode 100644 examples/spm-demo/src/typescript-estree.ts create mode 100644 examples/spm-demo/src/undici.ts create mode 100644 examples/spm-demo/tsconfig.json create mode 100644 packages/pm/.gitignore create mode 100644 packages/pm/package.json create mode 100755 packages/pm/scripts/smoke.sh create mode 100644 packages/pm/src/config.ts create mode 100644 packages/pm/src/fetch.ts create mode 100644 packages/pm/src/locate.ts create mode 100644 packages/pm/src/main.ts create mode 100644 packages/pm/src/manifest.ts create mode 100644 packages/pm/src/overlay.ts create mode 100644 packages/pm/src/pnpm.ts create mode 100644 packages/pm/src/published.ts create mode 100644 packages/pm/src/registry.ts create mode 100644 packages/pm/src/repo.ts create mode 100644 packages/pm/src/status.ts create mode 100644 packages/pm/src/sync.ts create mode 100644 packages/pm/src/tokens.ts create mode 100644 packages/pm/src/tree.ts create mode 100644 packages/pm/src/util.ts create mode 100644 packages/pm/src/verify.ts create mode 100644 packages/pm/tsconfig.json diff --git a/examples/spm-demo/.gitignore b/examples/spm-demo/.gitignore new file mode 100644 index 0000000000..359c537c5d --- /dev/null +++ b/examples/spm-demo/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.scriptc-pm/ +.scriptc/ +bin/ diff --git a/examples/spm-demo/package.json b/examples/spm-demo/package.json new file mode 100644 index 0000000000..391553e30d --- /dev/null +++ b/examples/spm-demo/package.json @@ -0,0 +1,22 @@ +{ + "name": "spm-demo", + "private": true, + "type": "module", + "dependencies": { + "@openfeature/core": "1.9.2", + "@openfeature/server-sdk": "1.18.0", + "@typescript-eslint/typescript-estree": "8.20.0", + "async-sema": "3.1.1", + "fast-glob": "3.3.3", + "path-to-regexp": "6.3.0", + "semver": "7.6.2", + "smol-toml": "1.9.0", + "ts-api-utils": "2.1.0", + "typescript": "5.7.3", + "undici": "5.29.0" + }, + "devDependencies": { + "@types/node": "24.0.0", + "@types/semver": "7.5.8" + } +} diff --git a/examples/spm-demo/pnpm-lock.yaml b/examples/spm-demo/pnpm-lock.yaml new file mode 100644 index 0000000000..36c14a29bf --- /dev/null +++ b/examples/spm-demo/pnpm-lock.yaml @@ -0,0 +1,353 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@openfeature/core': + specifier: 1.9.2 + version: 1.9.2 + '@openfeature/server-sdk': + specifier: 1.18.0 + version: 1.18.0(@openfeature/core@1.9.2) + '@typescript-eslint/typescript-estree': + specifier: 8.20.0 + version: 8.20.0(typescript@5.7.3) + async-sema: + specifier: 3.1.1 + version: 3.1.1 + fast-glob: + specifier: 3.3.3 + version: 3.3.3 + path-to-regexp: + specifier: 6.3.0 + version: 6.3.0 + semver: + specifier: 7.6.2 + version: 7.6.2 + smol-toml: + specifier: 1.9.0 + version: 1.9.0 + ts-api-utils: + specifier: 2.1.0 + version: 2.1.0(typescript@5.7.3) + typescript: + specifier: 5.7.3 + version: 5.7.3 + undici: + specifier: 5.29.0 + version: 5.29.0 + devDependencies: + '@types/node': + specifier: 24.0.0 + version: 24.0.0 + '@types/semver': + specifier: 7.5.8 + version: 7.5.8 + +packages: + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@openfeature/core@1.9.2': + resolution: {integrity: sha512-0lX0xYTflLrjiYNlareYmdV98xEddR5+PhcuoGvH+BMIqpZ2icAC7us9Uv86KRVqofXvpAUwpP32wgqmtUFs8Q==} + + '@openfeature/server-sdk@1.18.0': + resolution: {integrity: sha512-uP8nqEGBS58s3iWXx6d8vnJ6ZVt3DACJL4PWADOAuwIS4xXpID91783e9f6zQ0i1ijQpj3yx+3ZuCB2LfQMUMA==} + engines: {node: '>=18'} + peerDependencies: + '@openfeature/core': ^1.7.0 + + '@types/node@24.0.0': + resolution: {integrity: sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==} + + '@types/semver@7.5.8': + resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + + '@typescript-eslint/types@8.20.0': + resolution: {integrity: sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.20.0': + resolution: {integrity: sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/visitor-keys@8.20.0': + resolution: {integrity: sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@7.6.2: + resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + engines: {node: '>=10'} + hasBin: true + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + +snapshots: + + '@fastify/busboy@2.1.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@openfeature/core@1.9.2': {} + + '@openfeature/server-sdk@1.18.0(@openfeature/core@1.9.2)': + dependencies: + '@openfeature/core': 1.9.2 + + '@types/node@24.0.0': + dependencies: + undici-types: 7.8.0 + + '@types/semver@7.5.8': {} + + '@typescript-eslint/types@8.20.0': {} + + '@typescript-eslint/typescript-estree@8.20.0(typescript@5.7.3)': + dependencies: + '@typescript-eslint/types': 8.20.0 + '@typescript-eslint/visitor-keys': 8.20.0 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.9 + semver: 7.6.2 + ts-api-utils: 2.1.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.20.0': + dependencies: + '@typescript-eslint/types': 8.20.0 + eslint-visitor-keys: 4.2.1 + + async-sema@3.1.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + eslint-visitor-keys@4.2.1: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + ms@2.1.3: {} + + path-to-regexp@6.3.0: {} + + picomatch@2.3.2: {} + + queue-microtask@1.2.3: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + semver@7.6.2: {} + + smol-toml@1.9.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.7.3): + dependencies: + typescript: 5.7.3 + + typescript@5.7.3: {} + + undici-types@7.8.0: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 diff --git a/examples/spm-demo/pnpm-workspace.yaml b/examples/spm-demo/pnpm-workspace.yaml new file mode 100644 index 0000000000..3334c0e439 --- /dev/null +++ b/examples/spm-demo/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: [] diff --git a/examples/spm-demo/src/app.ts b/examples/spm-demo/src/app.ts new file mode 100644 index 0000000000..a09c989421 --- /dev/null +++ b/examples/spm-demo/src/app.ts @@ -0,0 +1,30 @@ +// A small "application" that uses several augmented packages together. +import { Sema } from "async-sema"; +import { InMemoryProvider, OpenFeature } from "@openfeature/server-sdk"; +import { match } from "path-to-regexp"; +import { parse } from "smol-toml"; + +const config = parse(`[routes]\nuser = "/user/:id"\n[limits]\nconcurrency = 2\n`) as { + routes: { user: string }; + limits: { concurrency: number }; +}; +await OpenFeature.setProviderAndWait( + new InMemoryProvider({ + "greet": { variants: { yes: true, no: false }, defaultVariant: "yes", disabled: false }, + }), +); +const flags = OpenFeature.getClient(); +const route = match<{ id: string }>(config.routes.user); +const sema = new Sema(config.limits.concurrency); +const out: string[] = []; +await Promise.all( + ["/user/1", "/user/2", "/nope"].map(async (path) => { + await sema.acquire(); + const hit = route(path); + const greet = await flags.getBooleanValue("greet", false); + out.push(hit === false ? `${path}: 404` : `${path}: ${greet ? "hello" : "hi"} ${hit.params.id}`); + sema.release(); + }), +); +console.log(out.sort().join("\n")); +await OpenFeature.close(); diff --git a/examples/spm-demo/src/async-sema.ts b/examples/spm-demo/src/async-sema.ts new file mode 100644 index 0000000000..22c09deac5 --- /dev/null +++ b/examples/spm-demo/src/async-sema.ts @@ -0,0 +1,16 @@ +import { Sema } from "async-sema"; + +const sema = new Sema(2); +const log: string[] = []; + +async function job(id: number): Promise { + await sema.acquire(); + log.push(`start ${id}`); + await new Promise((resolve) => setTimeout(resolve, 5 * (4 - id))); + log.push(`end ${id}`); + sema.release(); +} + +await Promise.all([job(1), job(2), job(3)]); +console.log(log.join("\n")); +console.log(`waiting=${sema.nrWaiting()}`); diff --git a/examples/spm-demo/src/fast-glob.ts b/examples/spm-demo/src/fast-glob.ts new file mode 100644 index 0000000000..8b18b2aac1 --- /dev/null +++ b/examples/spm-demo/src/fast-glob.ts @@ -0,0 +1,4 @@ +import fg from "fast-glob"; + +const files = fg.sync(["src/*.ts"], { cwd: process.cwd() }).sort(); +console.log(files.join("\n")); diff --git a/examples/spm-demo/src/openfeature.ts b/examples/spm-demo/src/openfeature.ts new file mode 100644 index 0000000000..a81a516a25 --- /dev/null +++ b/examples/spm-demo/src/openfeature.ts @@ -0,0 +1,13 @@ +import { InMemoryProvider, OpenFeature } from "@openfeature/server-sdk"; + +const provider = new InMemoryProvider({ + "new-ui": { variants: { on: true, off: false }, defaultVariant: "on", disabled: false }, + "color": { variants: { red: "red", blue: "blue" }, defaultVariant: "blue", disabled: false }, +}); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient(); +console.log(await client.getBooleanValue("new-ui", false)); +console.log(await client.getStringValue("color", "none")); +const details = await client.getBooleanDetails("missing", false); +console.log(`${details.flagKey} ${details.value} ${details.reason} ${details.errorCode}`); +await OpenFeature.close(); diff --git a/examples/spm-demo/src/path-to-regexp.ts b/examples/spm-demo/src/path-to-regexp.ts new file mode 100644 index 0000000000..6616638f9c --- /dev/null +++ b/examples/spm-demo/src/path-to-regexp.ts @@ -0,0 +1,8 @@ +import { compile, match, pathToRegexp } from "path-to-regexp"; + +const matchUser = match<{ id: string }>("/user/:id", { decode: decodeURIComponent }); +const hit = matchUser("/user/42"); +console.log(hit === false ? "no match" : `${hit.path} id=${hit.params.id}`); +console.log(matchUser("/team/1") === false ? "no match" : "match"); +console.log(compile<{ id: string }>("/user/:id")({ id: "7" })); +console.log(pathToRegexp("/files/:name.:ext").source); diff --git a/examples/spm-demo/src/semver.ts b/examples/spm-demo/src/semver.ts new file mode 100644 index 0000000000..fe8bb5cc5c --- /dev/null +++ b/examples/spm-demo/src/semver.ts @@ -0,0 +1,3 @@ +import semver from "semver"; + +console.log(semver.satisfies("1.2.3", "^1.0.0"), semver.inc("1.2.3", "minor")); diff --git a/examples/spm-demo/src/smol-toml.ts b/examples/spm-demo/src/smol-toml.ts new file mode 100644 index 0000000000..7ea1d5c95b --- /dev/null +++ b/examples/spm-demo/src/smol-toml.ts @@ -0,0 +1,11 @@ +import { parse, stringify } from "smol-toml"; + +const doc = parse(`title = "demo" +answer = 42 +tags = ["a", "b"] + +[owner] +name = "Tom" +`); +console.log(JSON.stringify(doc)); +console.log(stringify({ name: "spm", version: 1, nested: { ok: true, list: [1, 2, 3] } })); diff --git a/examples/spm-demo/src/ts-api-utils.ts b/examples/spm-demo/src/ts-api-utils.ts new file mode 100644 index 0000000000..6c7f1a5878 --- /dev/null +++ b/examples/spm-demo/src/ts-api-utils.ts @@ -0,0 +1,3 @@ +import { isTsVersionAtLeast } from "ts-api-utils"; + +console.log(isTsVersionAtLeast(4, 0)); diff --git a/examples/spm-demo/src/typescript-estree.ts b/examples/spm-demo/src/typescript-estree.ts new file mode 100644 index 0000000000..a9dc51f750 --- /dev/null +++ b/examples/spm-demo/src/typescript-estree.ts @@ -0,0 +1,5 @@ +import { parse } from "@typescript-eslint/typescript-estree"; + +const ast = parse("let answer: number = 40 + 2;", { range: false }); +const decl = ast.body[0]; +console.log(ast.type, decl?.type); diff --git a/examples/spm-demo/src/undici.ts b/examples/spm-demo/src/undici.ts new file mode 100644 index 0000000000..f047160025 --- /dev/null +++ b/examples/spm-demo/src/undici.ts @@ -0,0 +1,4 @@ +import { Headers } from "undici"; + +const headers = new Headers({ "x-demo": "1" }); +console.log(headers.get("x-demo")); diff --git a/examples/spm-demo/tsconfig.json b/examples/spm-demo/tsconfig.json new file mode 100644 index 0000000000..3ffe711b44 --- /dev/null +++ b/examples/spm-demo/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/pm/.gitignore b/packages/pm/.gitignore new file mode 100644 index 0000000000..e660fd93d3 --- /dev/null +++ b/packages/pm/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/packages/pm/package.json b/packages/pm/package.json new file mode 100644 index 0000000000..e241ba6d60 --- /dev/null +++ b/packages/pm/package.json @@ -0,0 +1,23 @@ +{ + "name": "@scriptc/pm", + "version": "0.1.0", + "private": true, + "description": "scriptc-pm (spm): a pnpm wrapper that augments installed npm packages with their verified upstream TypeScript source for scriptc (proof of concept)", + "license": "Apache-2.0", + "type": "module", + "bin": { + "scriptc-pm": "dist/main.js", + "spm": "dist/main.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", + "build:native": "node ../cli/dist/bootstrap.js build src/main.ts -o bin/spm", + "smoke": "sh scripts/smoke.sh" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/pm/scripts/smoke.sh b/packages/pm/scripts/smoke.sh new file mode 100755 index 0000000000..3e6fe05059 --- /dev/null +++ b/packages/pm/scripts/smoke.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# End-to-end smoke check: install the public demo app with real pnpm, then run +# `spm sync` under Node and as the scriptc-built native binary, each with a +# cold source cache, and require identical stdout, manifest, lock and overlay +# trees. Needs network access, pnpm, git and a built package +# (`pnpm --filter @scriptc/pm build && pnpm --filter @scriptc/pm build:native`). +set -eu +here=$(cd "$(dirname "$0")/.." && pwd) +repo=$(cd "$here/../.." && pwd) +work=${SPM_SMOKE_DIR:-$(mktemp -d)} +mkdir -p "$work" +demo="$work/demo" +rm -rf "$demo" "$work/out-node" "$work/out-native" +cp -R "$repo/examples/spm-demo" "$demo" +rm -rf "$demo/node_modules" "$demo/.scriptc-pm" +(cd "$demo" && pnpm install --silent) + +snapshot() { + out=$1 + mkdir -p "$out" + cp "$demo/.scriptc-pm/manifest.json" "$demo/scriptc-sources.lock.json" "$out/" + (cd "$demo/.scriptc-pm/overlay" && find . -type f -o -type l | LC_ALL=C sort | while read -r f; do + if [ -L "$f" ]; then echo "$f -> $(readlink "$f")"; else echo "$f $(sha256sum "$f" | cut -c1-16)"; fi + done) >"$out/overlay.txt" +} + +run_lane() { + lane=$1 + shift + rm -rf "$work/cache-$lane" + (cd "$demo" && SCRIPTC_PM_CACHE="$work/cache-$lane" "$@" sync >"$work/stdout-$lane.txt" 2>"$work/stderr-$lane.txt") || { + echo "smoke: $lane lane failed"; cat "$work/stderr-$lane.txt"; exit 1; } + sed -E 's/ \([0-9.]+s\)$//' "$work/stdout-$lane.txt" >"$work/stdout-$lane.norm" + snapshot "$work/out-$lane" +} + +run_lane node node "$here/dist/main.js" +run_lane native "$here/bin/spm" + +status=0 +for f in manifest.json scriptc-sources.lock.json overlay.txt; do + if ! cmp -s "$work/out-node/$f" "$work/out-native/$f"; then + echo "smoke: $f differs between Node and native"; diff "$work/out-node/$f" "$work/out-native/$f" | head -20; status=1 + fi +done +if ! cmp -s "$work/stdout-node.norm" "$work/stdout-native.norm"; then + echo "smoke: stdout differs between Node and native"; diff "$work/stdout-node.norm" "$work/stdout-native.norm" | head -20; status=1 +fi +augmented=$(grep -c '^augmented' "$work/stdout-node.norm" || true) +if [ "$augmented" -lt 1 ]; then echo "smoke: nothing was augmented"; status=1; fi +cat "$work/stdout-node.txt" +echo "smoke: node $(tail -2 "$work/stdout-node.txt" | head -1 | sed -E 's/.*\(([0-9.]+s)\)$/\1/'), native $(tail -2 "$work/stdout-native.txt" | head -1 | sed -E 's/.*\(([0-9.]+s)\)$/\1/')" +[ "$status" = 0 ] && echo "smoke: OK ($augmented augmented; Node and native outputs identical)" +exit $status diff --git a/packages/pm/src/config.ts b/packages/pm/src/config.ts new file mode 100644 index 0000000000..4c9870ea53 --- /dev/null +++ b/packages/pm/src/config.ts @@ -0,0 +1,47 @@ +// `scriptc-pm.json` (DESIGN.md §4.2), trimmed to what the POC implements. + +import { join } from "node:path"; +import { readText } from "./util.js"; + +export interface Override { + /** A local checkout of the package directory. */ + path?: string; + /** A git remote (https or ssh) plus ref and package directory. */ + repo?: string; + ref?: string; + dir?: string; +} + +/** The minimum accepted content level (DESIGN.md §7.3). */ +export type ContentPolicy = "exact" | "equivalent" | "structural" | "none"; + +export interface Config { + exclude: string[]; + overrides: Record; + content: ContentPolicy; +} + +interface RawConfig { + exclude?: string[]; + overrides?: Record; + trust?: { content?: string }; +} + +export function loadConfig(projectDir: string): Config { + const config: Config = { exclude: ["typescript"], overrides: {}, content: "equivalent" }; + const text = readText(join(projectDir, "scriptc-pm.json")); + if (text === null) return config; + const raw = JSON.parse(text) as RawConfig; + if (raw.exclude !== undefined) config.exclude = raw.exclude; + if (raw.overrides !== undefined) config.overrides = raw.overrides; + const content = raw.trust?.content; + if (content !== undefined) config.content = parseContentPolicy(content); + return config; +} + +export function parseContentPolicy(value: string): ContentPolicy { + if (value === "exact" || value === "equivalent" || value === "structural" || value === "none") { + return value; + } + throw new Error(`invalid content policy '${value}' (exact, equivalent, structural, none)`); +} diff --git a/packages/pm/src/fetch.ts b/packages/pm/src/fetch.ts new file mode 100644 index 0000000000..1f8a4d0e6e --- /dev/null +++ b/packages/pm/src/fetch.ts @@ -0,0 +1,254 @@ +// Fetching a repository tree at an exact commit into the content-addressed +// cache (DESIGN.md §5.2.1): a blobless, sparse git fetch when git is available, +// or the codeload archive with an in-process gzip + tar reader for public +// GitHub repositories. +// +// Cache layout: `/trees/////`. The git +// variant is a sparse checkout that starts with every `package.json` and the +// root configs (enough to locate the package directory) and is widened to the +// package directory on demand. + +import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { gunzipSync } from "node:zlib"; +import { cacheRoot } from "./registry.js"; +import type { RepoRef } from "./repo.js"; +import { baseEnv, errorMessage, isDir, isFile, readText, run } from "./util.js"; + +export type FetchMode = "git" | "codeload"; + +export interface Tree { + root: string; + mode: FetchMode; +} + +const inflight = new Map>(); +const widenLocks = new Map>(); + +/** Fetch (or reuse) the tree for `repo` at `commit`. Concurrent callers for + * the same tree share one fetch. */ +export function fetchTree(repo: RepoRef, commit: string, mode: FetchMode): Promise { + const key = `${repo.id}@${commit}`; + const existing = inflight.get(key); + if (existing !== undefined) return existing; + const promise = fetchTreeUncached(repo, commit, mode); + inflight.set(key, promise); + return promise; +} + +async function fetchTreeUncached(repo: RepoRef, commit: string, mode: FetchMode): Promise { + const dest = join(cacheRoot(), "trees", repo.id, commit); + if (isDir(join(dest, ".git"))) return { root: dest, mode: "git" }; + if (isFile(join(dest, ".spm-complete"))) return { root: dest, mode: "codeload" }; + mkdirSync(join(cacheRoot(), "tmp"), { recursive: true }); + const tmp = join( + cacheRoot(), + "tmp", + `${commit}-${process.pid}-${Math.random().toString(36).slice(2)}`, + ); + try { + let fetched: FetchMode = mode; + if (mode === "git") { + const err = await gitFetch(repo, commit, tmp); + if (err !== null) { + if (repo.githubPath === "") throw new Error(err); + // One transport fallback (DESIGN.md §8.7): git → archive. + rmSync(tmp, { recursive: true, force: true }); + await codeloadFetch(repo, commit, tmp); + fetched = "codeload"; + } + } else { + await codeloadFetch(repo, commit, tmp); + } + mkdirSync(dest.slice(0, dest.lastIndexOf("/")), { recursive: true }); + try { + renameSync(tmp, dest); + } catch { + // A concurrent process published the same tree first. + if (!isDir(dest)) throw new Error("source cache rename failed"); + } + return { root: dest, mode: fetched }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +/** Make `subdir` (and the root configs) present in a git tree. Codeload + * trees are complete already. */ +export async function widenTree(tree: Tree, subdir: string): Promise { + if (tree.mode !== "git") return; + const previous = widenLocks.get(tree.root) ?? Promise.resolve(); + const next = previous.then(() => widenTreeNow(tree.root, subdir)); + widenLocks.set( + tree.root, + next.catch(() => {}), + ); + return next; +} + +async function widenTreeNow(root: string, subdir: string): Promise { + const sparseFile = join(root, ".git", "info", "sparse-checkout"); + const current = readText(sparseFile) ?? ""; + const pattern = subdir === "" ? "/*" : `/${subdir}/`; + if (current.split("\n").includes(pattern)) return; + writeFileSync(sparseFile, `${current}${pattern}\n`); + const res = await run("git", ["sparse-checkout", "reapply"], root, gitEnv("")); + if (res.code !== 0) throw new Error(`git sparse-checkout failed: ${firstLine(res.stderr)}`); +} + +function gitEnv(host: string): Record { + const env = baseEnv(); + env["GIT_TERMINAL_PROMPT"] = "0"; + env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"; + const token = githubToken(); + if (host === "github.com" && token !== "") { + // Inject the header through env config: never in argv (DESIGN.md §8.2). + const basic = Buffer.from(`x-access-token:${token}`).toString("base64"); + env["GIT_CONFIG_COUNT"] = "1"; + env["GIT_CONFIG_KEY_0"] = "http.https://github.com/.extraheader"; + env["GIT_CONFIG_VALUE_0"] = `AUTHORIZATION: basic ${basic}`; + } + return env; +} + +function githubToken(): string { + return process.env["GITHUB_TOKEN"] ?? process.env["GH_TOKEN"] ?? ""; +} + +function firstLine(text: string): string { + const lines = text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== ""); + return lines.length === 0 ? "(no output)" : (lines[lines.length - 1] as string); +} + +/** Returns null on success or a reason string. */ +async function gitFetch(repo: RepoRef, commit: string, dir: string): Promise { + const env = gitEnv(repo.host); + mkdirSync(dir, { recursive: true }); + const init = await run("git", ["init", "-q"], dir, env); + if (init.code === 127) return "git-unavailable"; + if (init.code !== 0) return `git init failed: ${firstLine(init.stderr)}`; + const fetched = await run( + "git", + ["fetch", "-q", "--depth", "1", "--filter=blob:none", repo.remote, commit], + dir, + env, + ); + if (fetched.code !== 0) return classifyGitError(fetched.stderr); + const config = await run("git", ["config", "core.sparseCheckout", "true"], dir, env); + if (config.code !== 0) return `git config failed: ${firstLine(config.stderr)}`; + writeFileSync(join(dir, ".git", "info", "sparse-checkout"), "/*.json\npackage.json\n"); + const checkout = await run("git", ["checkout", "-q", "--detach", commit], dir, env); + if (checkout.code !== 0) return `git checkout failed: ${firstLine(checkout.stderr)}`; + return null; +} + +function classifyGitError(stderr: string): string { + if (/not our ref|couldn't find remote ref|unadvertised object/i.test(stderr)) { + return "commit-not-found"; + } + if (/repository not found|could not read username|authentication failed|403/i.test(stderr)) { + return "not-found-or-no-access"; + } + if (/permission denied \(publickey\)|host key verification/i.test(stderr)) return "ssh-batchmode"; + return `git fetch failed: ${firstLine(stderr)}`; +} + +async function codeloadFetch(repo: RepoRef, commit: string, dir: string): Promise { + if (repo.githubPath === "") throw new Error(`no archive endpoint for ${repo.host}`); + const base = process.env["SPM_CODELOAD"] ?? "https://codeload.github.com"; + const url = `${base}/${repo.githubPath}/tar.gz/${commit}`; + const token = githubToken(); + const headers: Record = {}; + if (token !== "") headers["authorization"] = `Bearer ${token}`; + let bytes: Buffer; + try { + const res = await fetch(url, { headers: headers, signal: AbortSignal.timeout(300_000) }); + if (res.status === 404) throw new Error("not-found-or-no-access"); + if (!res.ok) throw new Error(`archive fetch failed (HTTP ${res.status})`); + bytes = Buffer.from(await res.arrayBuffer()); + } catch (error) { + throw new Error(errorMessage(error)); + } + extractTarGz(bytes, dir, commit); + writeFileSync(join(dir, ".spm-complete"), `${commit}\n`); +} + +// ── tar ────────────────────────────────────────────────────────────────── + +function cString(bytes: Buffer, start: number, length: number): string { + let end = start; + while (end < start + length && bytes[end] !== 0) end += 1; + return bytes.toString("utf8", start, end); +} + +function octal(bytes: Buffer, start: number, length: number): number { + const text = cString(bytes, start, length).trim(); + return text === "" ? 0 : parseInt(text, 8); +} + +function paxRecords(body: string): Map { + const records = new Map(); + let i = 0; + while (i < body.length) { + const space = body.indexOf(" ", i); + if (space < 0) break; + const length = parseInt(body.slice(i, space), 10); + if (!(length > 0)) break; + const record = body.slice(space + 1, i + length - 1); + const eq = record.indexOf("="); + if (eq > 0) records.set(record.slice(0, eq), record.slice(eq + 1)); + i += length; + } + return records; +} + +/** Extract a GitHub archive (single top-level directory, stripped) into + * `dir`. Rejects absolute and `..` paths; skips symlinks and hardlinks. */ +export function extractTarGz(gz: Buffer, dir: string, commit: string): void { + const bytes = gunzipSync(gz); + let offset = 0; + let longName = ""; + let paxPath = ""; + let sawCommit = false; + while (offset + 512 <= bytes.length) { + if (bytes[offset] === 0) break; + const size = octal(bytes, offset + 124, 12); + const type = String.fromCharCode(bytes[offset + 156] ?? 48); + const dataStart = offset + 512; + const next = dataStart + Math.ceil(size / 512) * 512; + if (type === "g" || type === "x") { + const records = paxRecords(bytes.toString("utf8", dataStart, dataStart + size)); + if (type === "g" && records.get("comment") === commit) sawCommit = true; + if (type === "x") paxPath = records.get("path") ?? ""; + offset = next; + continue; + } + if (type === "L") { + longName = cString(bytes, dataStart, size); + offset = next; + continue; + } + let name = cString(bytes, offset, 100); + const prefix = cString(bytes, offset + 345, 155); + if (prefix !== "") name = `${prefix}/${name}`; + if (longName !== "") name = longName; + if (paxPath !== "") name = paxPath; + longName = ""; + paxPath = ""; + offset = next; + const slash = name.indexOf("/"); + const rel = slash < 0 ? "" : name.slice(slash + 1); + if (rel === "" || rel.startsWith("/") || rel.split("/").includes("..")) continue; + if (type === "5") { + mkdirSync(join(dir, rel), { recursive: true }); + } else if (type === "0" || type === "\0" || type === "7") { + const dest = join(dir, rel); + mkdirSync(dest.slice(0, dest.lastIndexOf("/")), { recursive: true }); + writeFileSync(dest, bytes.subarray(dataStart, dataStart + size)); + } + } + if (!sawCommit) throw new Error("archive does not carry the requested commit"); +} diff --git a/packages/pm/src/locate.ts b/packages/pm/src/locate.ts new file mode 100644 index 0000000000..a02a1a78fd --- /dev/null +++ b/packages/pm/src/locate.ts @@ -0,0 +1,177 @@ +// Source location strategies (DESIGN.md §5.2), in the POC's priority order: +// local override, source in the tarball (handled by the caller, which owns +// the published-package analysis), npm provenance, registry gitHead + +// repository, and version tags. + +import type { Override } from "./config.js"; +import type { Identity, LockEvidence, Strategy } from "./manifest.js"; +import type { VersionMeta } from "./registry.js"; +import { fetchProvenance } from "./registry.js"; +import type { RepoRef } from "./repo.js"; +import { parseRepoUrl } from "./repo.js"; +import { baseEnv, errorMessage, run } from "./util.js"; + +export interface RemoteCandidate { + strategy: Strategy; + identity: Identity; + repo: RepoRef; + commit: string; + dirHint: string; +} + +export interface LocateResult { + candidates: RemoteCandidate[]; + evidence: LockEvidence; + /** Why no candidate was found, when `candidates` is empty. */ + notes: string[]; +} + +/** Every remote candidate for an instance, strongest first. All signals are + * collected so the lock records corroboration (DESIGN.md §5.2). */ +export async function locateRemote( + name: string, + version: string, + meta: VersionMeta, + override: Override | undefined, +): Promise { + const evidence: LockEvidence = { gitHead: meta.gitHead, provenanceCommit: "", tag: "" }; + const candidates: RemoteCandidate[] = []; + const notes: string[] = []; + if (override?.repo !== undefined) { + const repo = parseRepoUrl(override.repo); + if (repo !== null && override.ref !== undefined) { + const commit = await resolveRef(repo, override.ref); + if (commit !== "") { + candidates.push({ + strategy: "override", + identity: "override", + repo: repo, + commit: commit, + dirHint: override.dir ?? "", + }); + } else { + notes.push(`override ref ${override.ref} not found`); + } + } + } + const registryRepo = parseRepoUrl(meta.repoUrl); + if (meta.hasAttestations) { + try { + const prov = await fetchProvenance(name, version, meta.integrity); + if (prov !== null) { + evidence.provenanceCommit = prov.commit; + const repo = parseRepoUrl(prov.repoUrl) ?? registryRepo; + if (repo !== null) { + candidates.push({ + strategy: "provenance", + identity: "provenance-unverified", + repo: repo, + commit: prov.commit, + dirHint: meta.repoDirectory, + }); + } + } + } catch (error) { + notes.push(`provenance: ${errorMessage(error)}`); + } + } + if (registryRepo === null) { + notes.push( + meta.repoUrl === "" + ? "no repository in registry metadata" + : `unparsed repository ${meta.repoUrl}`, + ); + return { candidates: candidates, evidence: evidence, notes: notes }; + } + const sameAsProvenance = + meta.gitHead === evidence.provenanceCommit && + candidates.some((c) => c.repo.id === registryRepo.id); + if (meta.gitHead !== "" && !sameAsProvenance) { + candidates.push({ + strategy: "gitHead", + identity: "gitHead", + repo: registryRepo, + commit: meta.gitHead, + dirHint: meta.repoDirectory, + }); + } + if (candidates.length === 0) { + const tag = await findVersionTag(registryRepo, name, version, meta.repoDirectory); + if (tag !== null) { + evidence.tag = tag.name; + candidates.push({ + strategy: "tag", + identity: "tag", + repo: registryRepo, + commit: tag.commit, + dirHint: meta.repoDirectory, + }); + } else { + notes.push("no gitHead, provenance or version tag"); + } + } + return { candidates: candidates, evidence: evidence, notes: notes }; +} + +interface TagHit { + name: string; + commit: string; +} + +async function lsRemoteTags(repo: RepoRef): Promise> { + const env = baseEnv(); + env["GIT_TERMINAL_PROMPT"] = "0"; + env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"; + const res = await run("git", ["ls-remote", "--tags", repo.remote], process.cwd(), env); + const tags = new Map(); + if (res.code !== 0) return tags; + for (const line of res.stdout.split("\n")) { + const [sha, ref] = line.split("\t"); + if (sha === undefined || ref === undefined || !ref.startsWith("refs/tags/")) continue; + const tagName = ref.slice("refs/tags/".length); + if (tagName.endsWith("^{}")) tags.set(tagName.slice(0, -3), sha); + else if (!tags.has(tagName)) tags.set(tagName, sha); + } + return tags; +} + +/** Conventional tags (DESIGN.md §5.2, S5); annotated tags are peeled. */ +async function findVersionTag( + repo: RepoRef, + name: string, + version: string, + dir: string, +): Promise { + const tags = await lsRemoteTags(repo); + const unscoped = name.startsWith("@") ? (name.split("/")[1] ?? name) : name; + const wanted = [ + `v${version}`, + version, + `${name}@${version}`, + `${unscoped}@${version}`, + `${name}-v${version}`, + `${unscoped}-v${version}`, + ]; + if (dir !== "") wanted.push(`${dir}/v${version}`); + for (const tagName of wanted) { + const commit = tags.get(tagName); + if (commit !== undefined) return { name: tagName, commit: commit }; + } + return null; +} + +async function resolveRef(repo: RepoRef, ref: string): Promise { + if (/^[0-9a-f]{40}$/.test(ref)) return ref; + const env = baseEnv(); + env["GIT_TERMINAL_PROMPT"] = "0"; + env["GIT_SSH_COMMAND"] = "ssh -o BatchMode=yes"; + const res = await run("git", ["ls-remote", repo.remote, ref, `${ref}^{}`], process.cwd(), env); + if (res.code !== 0) return ""; + let commit = ""; + for (const line of res.stdout.split("\n")) { + const [sha, name] = line.split("\t"); + if (sha === undefined || name === undefined) continue; + if (name.endsWith("^{}") || commit === "") commit = sha; + } + return commit; +} diff --git a/packages/pm/src/main.ts b/packages/pm/src/main.ts new file mode 100644 index 0000000000..467837bbc1 --- /dev/null +++ b/packages/pm/src/main.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// scriptc-pm (spm): a pnpm wrapper that augments installed npm packages with +// their verified upstream TypeScript source, for `scriptc --sources`. +// +// This program is compiled to a native binary by scriptc and also runs under +// Node; both must behave identically. + +import { resolve } from "node:path"; +import { loadConfig, parseContentPolicy } from "./config.js"; +import type { FetchMode } from "./fetch.js"; +import { GENERATOR } from "./manifest.js"; +import { pnpmInstall } from "./pnpm.js"; +import { status } from "./status.js"; +import { sync } from "./sync.js"; +import { errorMessage } from "./util.js"; + +const USAGE = `usage: spm [options] + +commands: + install [pnpm args...] pnpm install, then sync + sync augment installed packages with upstream TypeScript + status [--json] [--all] augmented packages and fallbacks with reasons + +sync/install options (before pnpm args): + --dir= project (pnpm lockfile) directory (default: cwd) + --fetch=git|codeload source transport (default: git, codeload fallback) + --content= minimum content level: exact|equivalent|structural|none + --concurrency= parallel instances (default: 8) + --verbose also list packages that are not candidates +`; + +interface Flags { + dir: string; + fetch: FetchMode; + content: string; + concurrency: number; + verbose: boolean; + json: boolean; + all: boolean; + rest: string[]; +} + +function parseFlags(args: string[]): Flags { + const flags: Flags = { + dir: process.cwd(), + fetch: "git", + content: "", + concurrency: 8, + verbose: false, + json: false, + all: false, + rest: [], + }; + let i = 0; + for (; i < args.length; i += 1) { + const arg = args[i] as string; + if (arg.startsWith("--dir=")) flags.dir = resolve(arg.slice("--dir=".length)); + else if (arg === "--fetch=git" || arg === "--fetch=codeload") + flags.fetch = arg === "--fetch=git" ? "git" : "codeload"; + else if (arg.startsWith("--content=")) flags.content = arg.slice("--content=".length); + else if (arg.startsWith("--concurrency=")) + flags.concurrency = Math.max(1, parseInt(arg.slice(14), 10) || 1); + else if (arg === "--verbose") flags.verbose = true; + else if (arg === "--json") flags.json = true; + else if (arg === "--all") flags.all = true; + else break; + } + for (; i < args.length; i += 1) flags.rest.push(args[i] as string); + return flags; +} + +async function runSync(flags: Flags): Promise { + const config = loadConfig(flags.dir); + if (flags.content !== "") config.content = parseContentPolicy(flags.content); + await sync({ + projectDir: flags.dir, + config: config, + fetchMode: flags.fetch, + concurrency: flags.concurrency, + verbose: flags.verbose, + }); + return 0; +} + +async function main(argv: string[]): Promise { + const command = argv[0] ?? ""; + const flags = parseFlags(argv.slice(1)); + switch (command) { + case "install": { + const code = await pnpmInstall(flags.dir, flags.rest); + if (code !== 0) return code; + return runSync(flags); + } + case "sync": + return runSync(flags); + case "status": + return status(flags.dir, flags.json, flags.all); + case "--version": + case "-v": + process.stdout.write(`${GENERATOR}\n`); + return 0; + case "": + case "help": + case "--help": + case "-h": + process.stdout.write(USAGE); + return command === "" ? 1 : 0; + default: + process.stderr.write(`spm: unknown command '${command}'\n${USAGE}`); + return 2; + } +} + +try { + const code = await main(process.argv.slice(2)); + process.exitCode = code; +} catch (error) { + process.stderr.write(`spm: ${errorMessage(error)}\n`); + process.exitCode = 1; +} diff --git a/packages/pm/src/manifest.ts b/packages/pm/src/manifest.ts new file mode 100644 index 0000000000..eafb42dab8 --- /dev/null +++ b/packages/pm/src/manifest.ts @@ -0,0 +1,123 @@ +// The two files scriptc-pm writes: the compiler handoff manifest +// (`.scriptc-pm/manifest.json`, schema 1 of DESIGN.md §5.5) and the committed +// lock (`scriptc-sources.lock.json`, DESIGN.md §4.3). +// +// JSON.stringify follows declaration order under scriptc (and insertion order +// under Node), so every record is built field by field in the order declared +// here. That keeps the Node and native outputs byte-identical. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +export const GENERATOR = "scriptc-pm 0.1.0"; +export const POLICY_VERSION = 1; + +export type Strategy = "override" | "tarball" | "provenance" | "gitHead" | "tag"; +export type Identity = "override" | "tarball" | "provenance-unverified" | "gitHead" | "tag"; +export type ContentLevel = "exact" | "equivalent" | "structural" | "unverified" | "mismatch"; +export type SurfaceLevel = "identical" | "drift" | "mismatch" | "unchecked"; + +/** condition → overlay-relative `.ts` path, per export subpath. */ +export type ExportMap = Record>; + +export interface SemanticOptions { + useDefineForClassFields: boolean; + experimentalDecorators: boolean; + esModuleInterop: boolean; +} + +export interface SourceRef { + repo: string; + commit: string; + subdir: string; + strategy: Strategy; +} + +export interface Verification { + identity: Identity; + content: ContentLevel; + surface: SurfaceLevel; +} + +export interface ManifestInstance { + installedDir: string; + name: string; + version: string; + overlayDir: string; + exports: ExportMap; + publishedFormat: string; + semanticOptions: SemanticOptions; + source: SourceRef; + verification: Verification; +} + +export interface Fallback { + name: string; + version: string; + reason: string; +} + +export interface Manifest { + schema: number; + generator: string; + lockDigest: string; + instances: ManifestInstance[]; + fallbacks: Fallback[]; +} + +export interface LockEvidence { + gitHead: string; + provenanceCommit: string; + tag: string; +} + +export interface LockVerification { + identity: Identity; + content: ContentLevel; + surface: SurfaceLevel; + checks: string[]; +} + +export interface LockEntry { + integrity: string; + strategy: Strategy; + evidence: LockEvidence; + repo: string; + commit: string; + subdir: string; + sourceTreeHash: string; + entryMap: ExportMap; + verification: LockVerification; +} + +export interface Lock { + lockfileVersion: number; + generator: string; + policyVersion: number; + packages: Record; +} + +/** Serialize the lock with sorted package keys and return its digest. */ +export function writeLock(projectDir: string, entries: Record): string { + const keys = Object.keys(entries).sort(); + const packages: Record = {}; + for (const key of keys) { + const entry = entries[key]; + if (entry !== undefined) packages[key] = entry; + } + const lock: Lock = { + lockfileVersion: 1, + generator: GENERATOR, + policyVersion: POLICY_VERSION, + packages: packages, + }; + const text = `${JSON.stringify(lock, null, 2)}\n`; + writeFileSync(join(projectDir, "scriptc-sources.lock.json"), text); + return `sha256-${createHash("sha256").update(text).digest("base64")}`; +} + +export function writeManifest(stateDir: string, manifest: Manifest): void { + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +} diff --git a/packages/pm/src/overlay.ts b/packages/pm/src/overlay.ts new file mode 100644 index 0000000000..38fcf33b72 --- /dev/null +++ b/packages/pm/src/overlay.ts @@ -0,0 +1,158 @@ +// Building and materializing an overlay (DESIGN.md §5.3, §5.4): +// entry mapping from published targets to source files, the closure check +// for modules the source imports but the tree lacks (codegen, §9.1), and the +// on-disk overlay with its `node_modules` link to pnpm's resolved deps. + +import { builtinModules } from "node:module"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, normalize } from "node:path"; +import type { ExportMap } from "./manifest.js"; +import type { Published } from "./published.js"; +import { importsOf, tokenize } from "./tokens.js"; +import { copyTree, gitBlobSha1, isDir, isFile, readText, sha256Hex } from "./util.js"; + +const BUILTINS = new Set(builtinModules); + +/** Map every published export target to a source file. Unmapped targets are + * returned in `unmapped`. Paths in the map are overlay-relative with "./". */ +export function buildExportMap( + pub: Published, + pubToSrc: Map, + srcDir: string, +): { exports: ExportMap; unmapped: string[] } { + const exports: ExportMap = {}; + const unmapped: string[] = []; + for (const t of pub.targets) { + const src = sourceForTarget(t.target, t.subpath, pubToSrc, srcDir); + if (src === null) { + unmapped.push(`${t.subpath} (${t.condition}: ${t.target})`); + continue; + } + // Written as a fresh non-union local: narrowing `exports[k]` from + // `Record | undefined` and writing through it trips a scriptc ICE. + const entry: Record = exports[t.subpath] ?? {}; + entry[t.condition] = `./${src}`; + entry["types"] = `./${src}`; + exports[t.subpath] = entry; + } + return { exports: exports, unmapped: unmapped }; +} + +function sourceForTarget( + target: string, + subpath: string, + pubToSrc: Map, + srcDir: string, +): string | null { + const direct = pubToSrc.get(target); + if (direct !== undefined) return direct; + // A bundle's map lists many sources; pick the one named like the bundle. + const stem = basename(target).replace(/\.[cm]?js$/, ""); + const guesses: string[] = []; + if (subpath === ".") + guesses.push(`src/${stem}.ts`, "src/index.ts", "index.ts", "lib/index.ts", "source/index.ts"); + else { + const sub = subpath.replace(/^\.\//, ""); + guesses.push(`src/${sub}.ts`, `src/${sub}/index.ts`, `${sub}.ts`); + } + for (const g of guesses) { + if (isFile(join(srcDir, g))) return g; + } + return null; +} + +/** Relative imports that do not resolve inside the overlay and bare imports + * that do not resolve to an installed dependency, over the runtime closure + * of the mapped entries. */ +export function closureProblems(srcDir: string, depsDir: string, entries: string[]): string[] { + const problems: string[] = []; + const seen = new Set(); + const queue = [...entries]; + while (queue.length > 0) { + const rel = queue.shift() as string; + if (seen.has(rel)) continue; + seen.add(rel); + const text = readText(join(srcDir, rel)); + if (text === null) continue; + for (const ref of importsOf(tokenize(text))) { + if (ref.typeOnly) continue; + const spec = ref.specifier; + if (spec.startsWith(".")) { + const target = resolveSource(srcDir, rel, spec); + if (target === null) problems.push(`missing-module: ${join(dirname(rel), spec)}`); + else if (/\.[cm]?tsx?$/.test(target)) queue.push(target); + continue; + } + if (spec.startsWith("node:") || spec.startsWith("#")) continue; + const name = spec.startsWith("@") + ? spec.split("/").slice(0, 2).join("/") + : (spec.split("/")[0] ?? spec); + if (BUILTINS.has(name)) continue; + if (!isDir(join(depsDir, name))) problems.push(`unresolved-import: ${spec} (from ${rel})`); + } + } + return [...new Set(problems)]; +} + +const SOURCE_EXTS = [".ts", ".tsx", ".mts", ".cts", ".d.ts", ".js", ".json"]; + +export function resolveSource(root: string, fromRel: string, spec: string): string | null { + const base = normalize(join(dirname(fromRel), spec)); + if (base.startsWith("..")) return null; + const candidates: string[] = []; + const m = /\.([cm]?)js$/.exec(base); + if (m !== null) { + const stem = base.slice(0, base.length - m[0].length); + candidates.push(`${stem}.${m[1] ?? ""}ts`, `${stem}.tsx`); + } + candidates.push(base); + for (const ext of SOURCE_EXTS) candidates.push(`${base}${ext}`); + for (const ext of SOURCE_EXTS) candidates.push(`${base}/index${ext}`); + for (const c of candidates) { + if (isFile(join(root, c))) return c; + } + return null; +} + +export interface OverlayInput { + overlayDir: string; + srcDir: string; + files: string[]; + depsDir: string; + name: string; + version: string; + type: string; + exports: ExportMap; + imports: unknown; +} + +interface OverlayPackageJson { + name: string; + version: string; + type: string; + exports: ExportMap; + imports?: unknown; +} + +/** Write the overlay and return its sourceTreeHash: sha256 over the sorted + * `path NUL gitBlobSha1` lines of the files taken from the source tree. */ +export function materializeOverlay(input: OverlayInput): string { + rmSync(input.overlayDir, { recursive: true, force: true }); + mkdirSync(input.overlayDir, { recursive: true }); + const taken = input.files.filter((f) => f !== "package.json"); + copyTree(input.srcDir, input.overlayDir, taken); + const pkg: OverlayPackageJson = { + name: input.name, + version: input.version, + type: input.type, + exports: input.exports, + }; + if (input.imports !== undefined) pkg.imports = input.imports; + writeFileSync(join(input.overlayDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`); + symlinkSync(input.depsDir, join(input.overlayDir, "node_modules")); + let lines = ""; + for (const rel of taken) { + lines += `${rel}\0${gitBlobSha1(readFileSync(join(input.srcDir, rel)))}\n`; + } + return `sha256-${sha256Hex(lines)}`; +} diff --git a/packages/pm/src/pnpm.ts b/packages/pm/src/pnpm.ts new file mode 100644 index 0000000000..f9aa29ae6a --- /dev/null +++ b/packages/pm/src/pnpm.ts @@ -0,0 +1,79 @@ +// Driving pnpm: run the CLI, then enumerate installed package instances from +// the isolated `node_modules/.pnpm` layout (DESIGN.md §4.5, §10.2). +// +// The layout is walked instead of parsing `pnpm ls --json --depth Infinity`: +// it is the same information (one directory per instance, the package as the +// only real directory in `.pnpm//node_modules`), it is stable across +// pnpm 9-12, and it avoids a multi-megabyte JSON tree with repeated subtrees. + +import { readdirSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { isDir, isSymlink, readText, runInherit } from "./util.js"; + +export interface Instance { + name: string; + version: string; + /** pnpm's instance directory name, e.g. `fast-glob@3.3.3`. */ + instanceId: string; + /** Realpath of the installed package directory. */ + installedDir: string; + /** `.pnpm//node_modules`: the package plus its resolved deps. */ + depsDir: string; +} + +export async function pnpmInstall(projectDir: string, args: string[]): Promise { + const pnpm = process.env["SPM_PNPM"] ?? "pnpm"; + return runInherit(pnpm, ["install", ...args], projectDir); +} + +interface PackageIdentity { + name?: string; + version?: string; +} + +export function enumerateInstances(projectDir: string): Instance[] { + const store = join(projectDir, "node_modules", ".pnpm"); + if (!isDir(store)) { + throw new Error( + "node_modules/.pnpm not found: scriptc-pm needs a pnpm install with the isolated linker", + ); + } + const instances: Instance[] = []; + for (const instanceId of readdirSync(store).sort()) { + const depsDir = join(store, instanceId, "node_modules"); + if (!isDir(depsDir)) continue; + for (const candidate of packageDirsIn(depsDir)) { + const text = readText(join(candidate, "package.json")); + if (text === null) continue; + const pkg = JSON.parse(text) as PackageIdentity; + if (pkg.name === undefined || pkg.version === undefined) continue; + instances.push({ + name: pkg.name, + version: pkg.version, + instanceId: instanceId, + installedDir: realpathSync(candidate), + depsDir: depsDir, + }); + } + } + return instances; +} + +/** The real (non-symlink) package directories directly inside `depsDir`. */ +function packageDirsIn(depsDir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(depsDir).sort()) { + if (entry === ".bin") continue; + const abs = join(depsDir, entry); + if (entry.startsWith("@")) { + if (!isDir(abs) || isSymlink(abs)) continue; + for (const inner of readdirSync(abs).sort()) { + const innerAbs = join(abs, inner); + if (!isSymlink(innerAbs) && isDir(innerAbs)) out.push(innerAbs); + } + } else if (!isSymlink(abs) && isDir(abs)) { + out.push(abs); + } + } + return out; +} diff --git a/packages/pm/src/published.ts b/packages/pm/src/published.ts new file mode 100644 index 0000000000..3c058cb8cb --- /dev/null +++ b/packages/pm/src/published.ts @@ -0,0 +1,199 @@ +// What the installed (published) package looks like: its manifest, its JS +// files, its source maps, and its export targets. Everything is read from the +// installed files, so no registry access is needed for the content check +// (DESIGN.md §8.1). + +import { dirname, join, normalize } from "node:path"; +import { isDir, isFile, readText, walkFiles } from "./util.js"; + +export interface SourceMapInfo { + /** The generated file, relative to the package root. */ + file: string; + /** Map sources resolved against the package root (may start with ../). */ + sources: string[]; + /** sourcesContent, aligned with `sources`; null where absent. */ + contents: (string | null)[]; +} + +export interface ExportTarget { + subpath: string; + condition: string; + /** Package-relative file, without "./". */ + target: string; +} + +export interface Published { + dir: string; + pkg: Record; + files: string[]; + jsFiles: string[]; + maps: SourceMapInfo[]; + targets: ExportTarget[]; + /** "cjs", "esm" or "dual" (DESIGN.md §5.6.7). */ + format: string; +} + +interface RawSourceMap { + sources?: string[]; + sourcesContent?: (string | null)[]; + sourceRoot?: string; +} + +const SKIP = new Set(["node_modules"]); +const RUNTIME_CONDITIONS = new Set(["import", "require", "node", "default", "module"]); + +export function readPublished(dir: string): Published { + const pkgText = readText(join(dir, "package.json")) ?? "{}"; + const pkg = JSON.parse(pkgText) as Record; + const files = walkFiles(dir, SKIP); + const jsFiles = files.filter((f) => /\.[cm]?js$/.test(f)); + const maps: SourceMapInfo[] = []; + for (const file of files) { + if (!/\.[cm]?js\.map$/.test(file)) continue; + const text = readText(join(dir, file)); + if (text === null) continue; + let raw: RawSourceMap; + try { + raw = JSON.parse(text) as RawSourceMap; + } catch { + continue; + } + const base = join(dirname(file), raw.sourceRoot ?? ""); + const sources = (raw.sources ?? []).map((s) => + s.startsWith("webpack://") ? s : normalize(join(base, s)), + ); + const contents: (string | null)[] = []; + for (let i = 0; i < sources.length; i += 1) contents.push(raw.sourcesContent?.[i] ?? null); + maps.push({ file: file.slice(0, -".map".length), sources: sources, contents: contents }); + } + const targets = exportTargets(pkg, dir); + return { + dir: dir, + pkg: pkg, + files: files, + jsFiles: jsFiles, + maps: maps, + targets: targets, + format: publishedFormat(pkg, targets), + }; +} + +function publishedFormat(pkg: Record, targets: ExportTarget[]): string { + let esm = false; + let cjs = false; + for (const t of targets) { + if (t.condition === "import" || t.target.endsWith(".mjs")) esm = true; + else if (t.condition === "require" || t.target.endsWith(".cjs")) cjs = true; + } + if (esm && cjs) return "dual"; + if (pkg["type"] === "module" || esm) return "esm"; + return "cjs"; +} + +/** Resolve a manifest path the way Node does for main: exact file, then + * `.js`, then `/index.js`. */ +function resolveTarget(dir: string, target: string): string | null { + const rel = normalize(target).replace(/^\.\//, ""); + if (rel.includes("*")) return null; + if (isFile(join(dir, rel))) return rel; + if (isFile(join(dir, `${rel}.js`))) return `${rel}.js`; + if (isDir(join(dir, rel)) && isFile(join(dir, rel, "index.js"))) return `${rel}/index.js`; + return null; +} + +function collectConditions( + value: unknown, + subpath: string, + outer: string, + dir: string, + out: ExportTarget[], +): void { + if (typeof value === "string") { + if (outer === "") return; + const target = resolveTarget(dir, value); + if (target !== null && !/\.d\.[cm]?ts$/.test(target)) { + out.push({ subpath: subpath, condition: outer, target: target }); + } + return; + } + if (Array.isArray(value)) { + const items = value as unknown[]; + if (items.length > 0) collectConditions(items[0], subpath, outer, dir, out); + return; + } + if (typeof value !== "object" || value === null) return; + const record = value as Record; + for (const key of Object.keys(record)) { + if (outer === "" && !RUNTIME_CONDITIONS.has(key)) continue; + collectConditions(record[key], subpath, outer === "" ? key : outer, dir, out); + } +} + +export function exportTargets(pkg: Record, dir: string): ExportTarget[] { + const out: ExportTarget[] = []; + const exp = pkg["exports"]; + if (exp !== undefined && exp !== null) { + if (typeof exp === "string" || Array.isArray(exp)) { + collectConditions(exp, ".", "default", dir, out); + } else if (typeof exp === "object") { + const record = exp as Record; + const keys = Object.keys(record); + if (keys.length > 0 && (keys[0] as string).startsWith(".")) { + for (const key of keys) { + if (key.includes("*") || key === "./package.json") continue; + collectConditions( + record[key], + key, + typeof record[key] === "string" ? "default" : "", + dir, + out, + ); + } + } else { + collectConditions(exp, ".", "", dir, out); + } + } + return out; + } + const main = typeof pkg["main"] === "string" ? (pkg["main"] as string) : "index.js"; + const mainTarget = resolveTarget(dir, main); + if (mainTarget !== null) out.push({ subpath: ".", condition: "default", target: mainTarget }); + if (typeof pkg["module"] === "string") { + const moduleTarget = resolveTarget(dir, pkg["module"] as string); + if (moduleTarget !== null) + out.push({ subpath: ".", condition: "import", target: moduleTarget }); + } + return out; +} + +/** The published declaration entry for the root subpath. */ +export function typesEntry(pub: Published): string | null { + const candidates: string[] = []; + const exp = pub.pkg["exports"]; + const findTypes = (value: unknown): void => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return; + const record = value as Record; + for (const key of Object.keys(record)) { + const inner = record[key]; + if (key === "types" && typeof inner === "string") candidates.push(inner); + else if (!key.startsWith(".") || key === ".") findTypes(inner); + } + }; + if (typeof exp === "object" && exp !== null) { + const record = exp as Record; + findTypes(record["."] !== undefined ? { ".": record["."] } : record); + } + for (const key of ["types", "typings"]) { + const value = pub.pkg[key]; + if (typeof value === "string") candidates.push(value); + } + for (const t of pub.targets) { + if (t.subpath === ".") candidates.push(t.target.replace(/\.([cm]?)js$/, ".d.$1ts")); + } + candidates.push("index.d.ts"); + for (const c of candidates) { + const rel = normalize(c).replace(/^\.\//, ""); + if (isFile(join(pub.dir, rel))) return rel; + } + return null; +} diff --git a/packages/pm/src/registry.ts b/packages/pm/src/registry.ts new file mode 100644 index 0000000000..f8f5f411fe --- /dev/null +++ b/packages/pm/src/registry.ts @@ -0,0 +1,141 @@ +// Registry metadata: the full version document (gitHead, repository) and the +// npm provenance attestation (DESIGN.md §5.2, S2/S3). Responses are cached +// under `/meta/` so a warm sync makes no metadata requests. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readText } from "./util.js"; + +export function cacheRoot(): string { + return process.env["SCRIPTC_PM_CACHE"] ?? join(homedir(), ".cache", "scriptc-pm"); +} + +function registryBase(): string { + const base = process.env["SPM_REGISTRY"] ?? "https://registry.npmjs.org"; + return base.endsWith("/") ? base.slice(0, -1) : base; +} + +export interface RepoField { + type?: string; + url?: string; + directory?: string; +} + +interface RawVersionDoc { + gitHead?: string; + repository?: string | RepoField; + dist?: { integrity?: string; attestations?: { url?: string } }; +} + +export interface VersionMeta { + gitHead: string; + repoUrl: string; + repoDirectory: string; + integrity: string; + hasAttestations: boolean; +} + +export interface Provenance { + repoUrl: string; + commit: string; +} + +async function cachedFetchText(url: string, cacheFile: string): Promise { + const cached = readText(cacheFile); + if (cached !== null) return cached === "" ? null : cached; + const res = await fetch(url, { signal: AbortSignal.timeout(30_000) }); + let text = ""; + if (res.ok) { + text = await res.text(); + } else if (res.status !== 404) { + throw new Error(`registry request failed (HTTP ${res.status} for ${url})`); + } + mkdirSync(cacheFile.slice(0, cacheFile.lastIndexOf("/")), { recursive: true }); + // A 404 is cached as an empty file: "known absent". + writeFileSync(cacheFile, text); + return text === "" ? null : text; +} + +export async function fetchVersionMeta(name: string, version: string): Promise { + const url = `${registryBase()}/${name}/${version}`; + const file = join(cacheRoot(), "meta", "registry.npmjs.org", name, `${version}.json`); + const text = await cachedFetchText(url, file); + if (text === null) throw new Error(`registry has no version document for ${name}@${version}`); + const doc = JSON.parse(text) as RawVersionDoc; + let repoUrl = ""; + let repoDirectory = ""; + const repo = doc.repository; + if (typeof repo === "string") { + repoUrl = repo; + } else if (repo !== undefined) { + repoUrl = repo.url ?? ""; + repoDirectory = repo.directory ?? ""; + } + return { + gitHead: doc.gitHead ?? "", + repoUrl: repoUrl, + repoDirectory: repoDirectory, + integrity: doc.dist?.integrity ?? "", + hasAttestations: doc.dist?.attestations !== undefined, + }; +} + +interface AttestationSet { + attestations?: { predicateType?: string; bundle?: { dsseEnvelope?: { payload?: string } } }[]; +} + +interface SlsaStatement { + subject?: { name?: string; digest?: { sha512?: string } }[]; + predicate?: { + buildDefinition?: { + resolvedDependencies?: { uri?: string; digest?: { gitCommit?: string } }[]; + }; + }; +} + +/** The SLSA provenance's source repo and commit. The Sigstore signature is + * NOT verified (scriptc has no public-key crypto yet, DESIGN.md §7.2); the + * subject digest is checked against the registry integrity instead, so the + * result is recorded as `provenance-unverified`. */ +export async function fetchProvenance( + name: string, + version: string, + integrity: string, +): Promise { + const url = `${registryBase()}/-/npm/v1/attestations/${name}@${version}`; + const file = join( + cacheRoot(), + "meta", + "registry.npmjs.org", + name, + `${version}.attestations.json`, + ); + const text = await cachedFetchText(url, file); + if (text === null) return null; + const set = JSON.parse(text) as AttestationSet; + for (const att of set.attestations ?? []) { + if (!(att.predicateType ?? "").startsWith("https://slsa.dev/provenance")) continue; + const payload = att.bundle?.dsseEnvelope?.payload; + if (payload === undefined) continue; + const stmt = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as SlsaStatement; + if (!subjectMatches(stmt, integrity)) { + throw new Error("provenance subject digest does not match the registry integrity"); + } + for (const dep of stmt.predicate?.buildDefinition?.resolvedDependencies ?? []) { + const commit = dep.digest?.gitCommit ?? ""; + const uri = dep.uri ?? ""; + if (commit !== "" && uri !== "") return { repoUrl: uri, commit: commit }; + } + } + return null; +} + +function subjectMatches(stmt: SlsaStatement, integrity: string): boolean { + if (!integrity.startsWith("sha512-")) return true; + const want = Buffer.from(integrity.slice("sha512-".length), "base64").toString("hex"); + for (const subject of stmt.subject ?? []) { + if ((subject.digest?.sha512 ?? "") === want) return true; + } + return false; +} diff --git a/packages/pm/src/repo.ts b/packages/pm/src/repo.ts new file mode 100644 index 0000000000..56af9d16bc --- /dev/null +++ b/packages/pm/src/repo.ts @@ -0,0 +1,58 @@ +// Repository URL normalization (DESIGN.md §8.6): every form a registry or a +// provenance statement uses maps to one canonical, transport-neutral id +// (`host/owner/repo`) plus the remote git should fetch from. + +export interface RepoRef { + /** Canonical id: lowercase host, no scheme, userinfo or `.git`. */ + id: string; + host: string; + /** The URL handed to git. SSH remotes stay SSH. */ + remote: string; + /** `owner/repo` for github.com, empty otherwise. */ + githubPath: string; +} + +export function parseRepoUrl(raw: string): RepoRef | null { + let url = raw.trim(); + if (url === "") return null; + let ssh = false; + if (url.startsWith("github:")) url = `https://github.com/${url.slice("github:".length)}`; + else if (url.startsWith("gitlab:")) url = `https://gitlab.com/${url.slice("gitlab:".length)}`; + else if (url.startsWith("bitbucket:")) { + url = `https://bitbucket.org/${url.slice("bitbucket:".length)}`; + } else if (/^[\w.-]+\/[\w.-]+$/.test(url)) url = `https://github.com/${url}`; + if (url.startsWith("git+")) url = url.slice(4); + // scp-like: git@host:owner/repo.git + const scp = /^[\w.-]+@([\w.-]+):(.+)$/.exec(url); + let host = ""; + let path = ""; + if (scp !== null) { + ssh = true; + host = scp[1] ?? ""; + path = scp[2] ?? ""; + } else { + const m = /^([a-z+]+):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(url); + if (m === null) return null; + ssh = (m[1] ?? "") === "ssh"; + host = m[2] ?? ""; + path = m[3] ?? ""; + } + host = host.toLowerCase(); + path = path.split("#")[0] ?? ""; + if (path.endsWith("/")) path = path.slice(0, -1); + if (path.endsWith(".git")) path = path.slice(0, -4); + // Browser URLs such as github.com/o/r/tree/main/packages/x keep only o/r. + // Provenance URIs carry a ref: github.com/o/r@refs/heads/main. + const at = path.indexOf("@"); + if (at >= 0) path = path.slice(0, at); + const segments = path.split("/").filter((s) => s !== ""); + if (host === "github.com" && segments.length > 2) segments.length = 2; + if (segments.length < 2) return null; + if ((segments[segments.length - 1] as string).endsWith(".git")) { + segments[segments.length - 1] = (segments[segments.length - 1] as string).slice(0, -4); + } + path = segments.join("/"); + const id = `${host}/${path}`; + const remote = ssh ? raw.replace(/^git\+/, "") : `https://${host}/${path}.git`; + return { id: id, host: host, remote: remote, githubPath: host === "github.com" ? path : "" }; +} diff --git a/packages/pm/src/status.ts b/packages/pm/src/status.ts new file mode 100644 index 0000000000..8aff11e8a9 --- /dev/null +++ b/packages/pm/src/status.ts @@ -0,0 +1,82 @@ +// `spm status`: which instances were augmented and which fell back, with +// reason codes, read back from the generated manifest. + +import { join } from "node:path"; +import type { Manifest } from "./manifest.js"; +import { readText } from "./util.js"; + +function pad(text: string, width: number): string { + return text.length >= width ? text : text + " ".repeat(width - text.length); +} + +/** The reason code: the text before the first ":" ("upstream-js", ...). */ +function reasonCode(reason: string): string { + const colon = reason.indexOf(":"); + if (colon < 0) return reason; + const code = reason.slice(0, colon); + if (code === "not-candidate") { + const rest = reason.slice(colon + 1).trim(); + return `not-candidate/${rest.split(/[ :(]/)[0] ?? rest}`; + } + return code; +} + +export function status(projectDir: string, json: boolean, all: boolean): number { + const text = readText(join(projectDir, ".scriptc-pm", "manifest.json")); + if (text === null) { + process.stderr.write( + "spm: no .scriptc-pm/manifest.json; run `spm install` or `spm sync` first\n", + ); + return 1; + } + const manifest = JSON.parse(text) as Manifest; + const counts = new Map(); + for (const f of manifest.fallbacks) { + const code = reasonCode(f.reason); + counts.set(code, (counts.get(code) ?? 0) + 1); + } + if (json) { + const summary: Record = {}; + for (const code of [...counts.keys()].sort()) summary[code] = counts.get(code) ?? 0; + const out = { + augmented: manifest.instances.map((i) => ({ + name: i.name, + version: i.version, + strategy: i.source.strategy, + identity: i.verification.identity, + content: i.verification.content, + surface: i.verification.surface, + })), + fallbacks: manifest.fallbacks + .filter((f) => all || !f.reason.startsWith("not-candidate")) + .map((f) => ({ + name: f.name, + version: f.version, + code: reasonCode(f.reason), + reason: f.reason, + })), + reasonCounts: summary, + }; + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return 0; + } + for (const i of manifest.instances) { + const where = + i.source.commit === "" + ? i.source.strategy + : `${i.source.repo}@${i.source.commit.slice(0, 7)}`; + process.stdout.write( + `augmented ${pad(`${i.name}@${i.version}`, 46)} ${pad(where, 52)} ${pad(i.verification.identity, 22)} ` + + `${i.verification.content}/${i.verification.surface}\n`, + ); + } + for (const f of manifest.fallbacks) { + if (!all && f.reason.startsWith("not-candidate")) continue; + process.stdout.write(`fallback ${pad(`${f.name}@${f.version}`, 46)} ${f.reason}\n`); + } + const codes = [...counts.keys()].sort().map((c) => `${c}=${counts.get(c) ?? 0}`); + process.stdout.write( + `${manifest.instances.length} augmented, ${manifest.fallbacks.length} not augmented (${codes.join(", ")})\n`, + ); + return 0; +} diff --git a/packages/pm/src/sync.ts b/packages/pm/src/sync.ts new file mode 100644 index 0000000000..561cddc2ee --- /dev/null +++ b/packages/pm/src/sync.ts @@ -0,0 +1,546 @@ +// `spm sync`: the per-instance pipeline of DESIGN.md §5.1. +// +// enumerate → pre-filter → locate (override, tarball, provenance, gitHead, +// tag) → fetch tree → locate package dir → verify (content, surface, +// closure) → policy → overlay + manifest + lock, or a fallback record. + +import { mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { Config, ContentPolicy } from "./config.js"; +import type { FetchMode } from "./fetch.js"; +import { fetchTree, widenTree } from "./fetch.js"; +import { locateRemote } from "./locate.js"; +import type { + ContentLevel, + Fallback, + Identity, + LockEntry, + LockEvidence, + ManifestInstance, + Strategy, +} from "./manifest.js"; +import { GENERATOR, writeLock, writeManifest } from "./manifest.js"; +import { buildExportMap, closureProblems, materializeOverlay, resolveSource } from "./overlay.js"; +import type { Instance } from "./pnpm.js"; +import { enumerateInstances } from "./pnpm.js"; +import type { Published } from "./published.js"; +import { readPublished } from "./published.js"; +import { cacheRoot, fetchVersionMeta } from "./registry.js"; +import type { RepoRef } from "./repo.js"; +import { importsOf, tokenize } from "./tokens.js"; +import type { TsOptions } from "./tree.js"; +import { + effectiveTsconfig, + isSourceFile, + locatePackageDir, + optionBool, + sourceFilesOf, + useDefineForClassFields, +} from "./tree.js"; +import { errorMessage, isFile, pool, readText, walkFiles } from "./util.js"; +import type { ContentResult, Emitter } from "./verify.js"; +import { exactCheck, findTsc, materializeFiles, reemitCheck, surfaceCheck } from "./verify.js"; + +export interface SyncOptions { + projectDir: string; + config: Config; + fetchMode: FetchMode; + concurrency: number; + verbose: boolean; +} + +type OutcomeKind = "augmented" | "fallback" | "skipped"; + +interface Outcome { + kind: OutcomeKind; + instance: Instance; + reason: string; + record: ManifestInstance | null; + lock: LockEntry | null; + line: string; +} + +interface Context { + options: SyncOptions; + stateDir: string; + tsc: Emitter | null; +} + +/** A located candidate source directory, before verification. */ +interface Located { + strategy: Strategy; + identity: Identity; + repo: string; + commit: string; + subdir: string; + treeRoot: string; + srcDir: string; + evidence: LockEvidence; + integrity: string; + /** Overlay file list override (S1 takes only TS sources). */ + onlyFiles: string[] | null; +} + +const OVERLAY_SKIP = new Set([ + "node_modules", + ".git", + "tests", + "__tests__", + "fixtures", + "coverage", + "docs", + "examples", + "benchmarks", +]); + +const CONTENT_RANK: Record = { + exact: 4, + equivalent: 3, + structural: 2, + unverified: 1, + mismatch: 0, +}; + +const POLICY_RANK: Record = { + exact: 4, + equivalent: 3, + structural: 2, + none: 1, +}; + +function short(commit: string): string { + return commit.slice(0, 7); +} + +function pad(text: string, width: number): string { + return text.length >= width ? text : text + " ".repeat(width - text.length); +} + +function skipped(instance: Instance, reason: string): Outcome { + return { + kind: "skipped", + instance: instance, + reason: reason, + record: null, + lock: null, + line: "", + }; +} + +function fallback(instance: Instance, reason: string): Outcome { + const id = `${instance.name}@${instance.version}`; + return { + kind: "fallback", + instance: instance, + reason: reason, + record: null, + lock: null, + line: `fallback ${pad(id, 46)} ${reason}`, + }; +} + +/** Packages that are never augmented (DESIGN.md §5.1 pre-filter). */ +function prefilter(instance: Instance, pub: Published, config: Config): string { + if (config.exclude.includes(instance.name)) return "excluded"; + if (instance.name.startsWith("@types/") || pub.jsFiles.length === 0) + return "types-only (no runtime JS)"; + if ( + pub.files.some((f) => f.endsWith(".node") || f === "binding.gyp" || f.startsWith("prebuilds/")) + ) { + return "native-addon"; + } + const hasDts = pub.files.some((f) => /\.d\.[cm]?ts$/.test(f)); + const hasTsMaps = pub.maps.some((m) => m.sources.some((s) => /\.[cm]?tsx?$/.test(s))); + const shipsTs = pub.files.some((f) => isSourceFile(f)); + if (!hasDts && !hasTsMaps && !shipsTs) + return "no-ts-signal (no declarations, TS maps or TS sources shipped)"; + return ""; +} + +// ── S1: source shipped in the tarball ──────────────────────────────────── + +/** Relative runtime imports among `files` that do not resolve to a file in + * the set: the "complete only if every module is present" rule. */ +function incompleteness(root: string, files: string[]): string { + for (const rel of files) { + const text = readText(join(root, rel)) ?? ""; + for (const ref of importsOf(tokenize(text))) { + if (ref.typeOnly || !ref.specifier.startsWith(".")) continue; + if (resolveSource(root, rel, ref.specifier) === null) + return `${rel} imports missing ${ref.specifier}`; + } + } + return ""; +} + +function locateInTarball(instance: Instance, pub: Published, integrity: string): Located | string { + const shipped = pub.files.filter((f) => isSourceFile(f) && !f.startsWith("node_modules/")); + const base: Located = { + strategy: "tarball", + identity: "tarball", + repo: "", + commit: "", + subdir: "", + treeRoot: pub.dir, + srcDir: pub.dir, + evidence: { gitHead: "", provenanceCommit: "", tag: "" }, + integrity: integrity, + onlyFiles: null, + }; + if (shipped.length > 0) { + const gap = incompleteness(pub.dir, shipped); + if (gap === "") { + base.onlyFiles = shipped; + return base; + } + return `shipped .ts incomplete: ${gap}`; + } + const fromMaps = new Map(); + for (const map of pub.maps) { + for (let i = 0; i < map.sources.length; i += 1) { + const source = map.sources[i] as string; + const content = map.contents[i] ?? null; + if (content === null || !isSourceFile(source)) continue; + if (source.startsWith("..") || source.includes("node_modules/")) continue; + fromMaps.set(source, content); + } + } + if (fromMaps.size === 0) return "no TS in tarball"; + const dir = join(cacheRoot(), "tarball", instance.name, instance.version); + materializeFiles(dir, fromMaps); + const files = [...fromMaps.keys()].sort(); + const gap = incompleteness(dir, files); + if (gap !== "") return `sourcesContent incomplete: ${gap}`; + base.treeRoot = dir; + base.srcDir = dir; + base.onlyFiles = files; + return base; +} + +// ── remote strategies ──────────────────────────────────────────────────── + +async function locateSource( + ctx: Context, + instance: Instance, + pub: Published, + allowTarball: boolean, +): Promise { + const config = ctx.options.config; + const override = config.overrides[instance.name]; + const evidence: LockEvidence = { gitHead: "", provenanceCommit: "", tag: "" }; + if (override?.path !== undefined) { + const dir = resolve(ctx.options.projectDir, override.path); + return { + strategy: "override", + identity: "override", + repo: "", + commit: "", + subdir: "", + treeRoot: dir, + srcDir: dir, + evidence: evidence, + integrity: "", + onlyFiles: null, + }; + } + const notes: string[] = []; + const meta = await fetchVersionMeta(instance.name, instance.version); + if (allowTarball) { + const tarball = locateInTarball(instance, pub, meta.integrity); + if (typeof tarball !== "string") return tarball; + notes.push(tarball); + } + const located = await locateRemote(instance.name, instance.version, meta, override); + for (const candidate of located.candidates) { + const result = await fetchCandidate( + ctx, + instance, + candidate.repo, + candidate.commit, + candidate.dirHint, + ); + if (typeof result === "string") { + notes.push(`${candidate.strategy}: ${result}`); + continue; + } + return { + strategy: candidate.strategy, + identity: candidate.identity, + repo: candidate.repo.id, + commit: candidate.commit, + subdir: result.subdir, + treeRoot: result.root, + srcDir: result.subdir === "" ? result.root : join(result.root, result.subdir), + evidence: located.evidence, + integrity: meta.integrity, + onlyFiles: null, + }; + } + for (const note of located.notes) notes.push(note); + return `no-source: ${notes.filter((n) => n !== "no TS in tarball").join("; ") || "no candidate"}`; +} + +async function fetchCandidate( + ctx: Context, + instance: Instance, + repo: RepoRef, + commit: string, + dirHint: string, +): Promise<{ root: string; subdir: string } | string> { + try { + const tree = await fetchTree(repo, commit, ctx.options.fetchMode); + const subdir = locatePackageDir(tree.root, instance.name, instance.version, dirHint); + if (subdir === null) return `package ${instance.name} not found in ${repo.id}@${short(commit)}`; + await widenTree(tree, subdir); + return { root: tree.root, subdir: subdir }; + } catch (error) { + return errorMessage(error); + } +} + +// ── verification + overlay ─────────────────────────────────────────────── + +function usesDecorators(srcDir: string, files: string[]): boolean { + for (const rel of files) { + const tokens = tokenize(readText(join(srcDir, rel)) ?? ""); + for (let i = 0; i + 1 < tokens.length; i += 1) { + const t = tokens[i]; + const next = tokens[i + 1]; + if (t?.kind === "punct" && t.value === "@" && next?.kind === "id") return true; + } + } + return false; +} + +function accepted(level: ContentLevel, identity: Identity, policy: ContentPolicy): boolean { + if (level === "mismatch") return false; + if (identity === "override") return true; + return CONTENT_RANK[level] >= POLICY_RANK[policy]; +} + +async function verifyAndMaterialize( + ctx: Context, + instance: Instance, + pub: Published, + loc: Located, +): Promise { + const ts: TsOptions = effectiveTsconfig(loc.treeRoot, loc.srcDir); + const sourceFiles = loc.onlyFiles ?? sourceFilesOf(loc.srcDir, ts); + const where = loc.commit === "" ? loc.strategy : `${loc.repo}@${short(loc.commit)}`; + if (sourceFiles.length === 0) + return fallback(instance, `upstream-js: no .ts sources at ${where}`); + let content: ContentResult | null = + loc.strategy === "tarball" ? null : exactCheck(pub, loc.srcDir); + if (content === null) { + if (ctx.tsc === null) + return fallback(instance, "no-emitter: no TypeScript compiler found for the content check"); + content = await reemitCheck(ctx.tsc, pub, loc.srcDir, sourceFiles, ts); + } + const level = content.level; + if (level === "unverified" && content.checks.length === 0) { + return fallback(instance, `unverifiable: ${content.detail}`); + } + if (level === "mismatch") { + return fallback(instance, `content-mismatch: ${content.detail} [${content.checks.join(", ")}]`); + } + if (!accepted(level, loc.identity, ctx.options.config.content)) { + const why = content.detail !== "" ? `: ${content.detail}` : ""; + return fallback( + instance, + `content-${level}${why} [${content.checks.join(", ")}] below policy ${ctx.options.config.content}`, + ); + } + const mapped = buildExportMap(pub, content.pubToSrc, loc.srcDir); + const root = mapped.exports["."]; + if (root === undefined) { + return fallback( + instance, + `entry-unmapped: ${mapped.unmapped.slice(0, 2).join(", ") || "no root export"}`, + ); + } + const entries = new Set(); + for (const sub of Object.keys(mapped.exports)) { + for (const file of Object.values(mapped.exports[sub] ?? {})) entries.add(file.slice(2)); + } + const rootEntry = Object.values(root)[0]?.slice(2) ?? ""; + const surface = surfaceCheck(pub, loc.srcDir, rootEntry); + if (surface.level === "mismatch") + return fallback(instance, `surface-mismatch: ${surface.detail}`); + const problems = closureProblems(loc.srcDir, instance.depsDir, [...entries]); + if (problems.length > 0) return fallback(instance, problems[0] as string); + const experimentalDecorators = optionBool(ts, "experimentalDecorators") ?? false; + if (experimentalDecorators && usesDecorators(loc.srcDir, sourceFiles)) { + return fallback(instance, "semantic-options: legacy experimentalDecorators in use"); + } + const overlayDir = join(ctx.stateDir, "overlay", instance.instanceId, "pkg"); + const files = + loc.onlyFiles ?? walkFiles(loc.srcDir, OVERLAY_SKIP).filter((f) => !f.endsWith(".map")); + const srcPkg = readText(join(loc.srcDir, "package.json")); + const srcPkgJson = srcPkg === null ? {} : (JSON.parse(srcPkg) as Record); + const typeField = srcPkgJson["type"] ?? pub.pkg["type"]; + const sourceTreeHash = materializeOverlay({ + overlayDir: overlayDir, + srcDir: loc.srcDir, + files: files, + depsDir: instance.depsDir, + name: instance.name, + version: instance.version, + type: typeof typeField === "string" ? typeField : "commonjs", + exports: mapped.exports, + imports: srcPkgJson["imports"], + }); + const checks = [...content.checks, `surface ${surface.level}`]; + if (mapped.unmapped.length > 0) checks.push(`unmapped ${mapped.unmapped.join(", ")}`); + if (ts.partial) checks.push("tsconfig partial"); + const useDefine = useDefineForClassFields(ts); + if (!useDefine) checks.push("useDefineForClassFields=false (scriptc uses define semantics)"); + const record: ManifestInstance = { + installedDir: instance.installedDir, + name: instance.name, + version: instance.version, + overlayDir: overlayDir, + exports: mapped.exports, + publishedFormat: pub.format, + semanticOptions: { + useDefineForClassFields: useDefine, + experimentalDecorators: experimentalDecorators, + esModuleInterop: optionBool(ts, "esModuleInterop") ?? false, + }, + source: { repo: loc.repo, commit: loc.commit, subdir: loc.subdir, strategy: loc.strategy }, + verification: { identity: loc.identity, content: level, surface: surface.level }, + }; + const lock: LockEntry = { + integrity: loc.integrity, + strategy: loc.strategy, + evidence: loc.evidence, + repo: loc.repo, + commit: loc.commit, + subdir: loc.subdir, + sourceTreeHash: sourceTreeHash, + entryMap: mapped.exports, + verification: { + identity: loc.identity, + content: level, + surface: surface.level, + checks: checks, + }, + }; + const id = `${instance.name}@${instance.version}`; + const line = `augmented ${pad(id, 46)} ${pad(where, 52)} ${pad(loc.strategy, 10)} ${level}/${surface.level}`; + return { + kind: "augmented", + instance: instance, + reason: "", + record: record, + lock: lock, + line: line, + }; +} + +async function processInstance(ctx: Context, instance: Instance): Promise { + const pub = readPublished(instance.installedDir); + const skip = prefilter(instance, pub, ctx.options.config); + if (skip !== "") return skipped(instance, skip); + try { + const loc = await locateSource(ctx, instance, pub, true); + if (typeof loc === "string") return fallback(instance, loc); + const outcome = await verifyAndMaterialize(ctx, instance, pub, loc); + if (outcome.kind === "augmented" || loc.strategy !== "tarball") return outcome; + // Tarball source was found but not accepted (typically: no tsconfig, so + // the re-emit could not reproduce the build). Try the upstream tree. + const remote = await locateSource(ctx, instance, pub, false); + if (typeof remote === "string") { + return fallback(instance, `${outcome.reason}; ${remote}`); + } + return await verifyAndMaterialize(ctx, instance, pub, remote); + } catch (error) { + return fallback(instance, `error: ${errorMessage(error)}`); + } +} + +export interface SyncSummary { + instances: number; + candidates: number; + augmented: number; + fallbacks: number; +} + +export async function sync(options: SyncOptions): Promise { + const started = Date.now(); + const stateDir = join(options.projectDir, ".scriptc-pm"); + const instances = enumerateInstances(options.projectDir); + const ctx: Context = { options: options, stateDir: stateDir, tsc: findTsc(options.projectDir) }; + rmSync(join(stateDir, "overlay"), { recursive: true, force: true }); + mkdirSync(stateDir, { recursive: true }); + const outcomes = await pool(instances, options.concurrency, (instance) => + processInstance(ctx, instance), + ); + // MVP (DESIGN.md §5.6.3): only names with one augmented instance are + // redirected; the rest compile from published JS. + const augmentedByName = new Map(); + for (const o of outcomes) { + if (o.kind === "augmented") + augmentedByName.set(o.instance.name, (augmentedByName.get(o.instance.name) ?? 0) + 1); + } + const instancesOut: ManifestInstance[] = []; + const fallbacks: Fallback[] = []; + const lockEntries: Record = {}; + let candidates = 0; + for (const o of outcomes) { + const instance = o.instance; + if (o.kind !== "skipped") candidates += 1; + if (o.kind === "augmented" && o.record !== null && o.lock !== null) { + const count = augmentedByName.get(instance.name) ?? 0; + if (count > 1) { + const reason = `ambiguous-name: ${count} augmented instances of ${instance.name}`; + fallbacks.push({ name: instance.name, version: instance.version, reason: reason }); + process.stdout.write(`${fallback(instance, reason).line}\n`); + rmSync(join(stateDir, "overlay", instance.instanceId), { recursive: true, force: true }); + continue; + } + instancesOut.push(o.record); + lockEntries[`${instance.name}@${instance.version}`] = o.lock; + process.stdout.write(`${o.line}\n`); + continue; + } + const reason = o.kind === "skipped" ? `not-candidate: ${o.reason}` : o.reason; + fallbacks.push({ name: instance.name, version: instance.version, reason: reason }); + if (o.kind === "fallback" || options.verbose) { + process.stdout.write(`${o.kind === "fallback" ? o.line : fallback(instance, reason).line}\n`); + } + } + const lockDigest = writeLock(options.projectDir, lockEntries); + writeManifest(stateDir, { + schema: 1, + generator: GENERATOR, + lockDigest: lockDigest, + instances: instancesOut, + fallbacks: fallbacks, + }); + warnIfNotIgnored(options.projectDir); + const seconds = ((Date.now() - started) / 1000).toFixed(1); + process.stdout.write( + `spm: ${instances.length} instances, ${candidates} source candidates, ${instancesOut.length} augmented, ` + + `${candidates - instancesOut.length} fallback (${seconds}s)\n`, + ); + process.stdout.write(`wrote .scriptc-pm/manifest.json, scriptc-sources.lock.json\n`); + return { + instances: instances.length, + candidates: candidates, + augmented: instancesOut.length, + fallbacks: candidates - instancesOut.length, + }; +} + +function warnIfNotIgnored(projectDir: string): void { + const gitignore = readText(join(projectDir, ".gitignore")); + if ( + !isFile(join(projectDir, ".gitignore")) || + gitignore === null || + !gitignore.includes(".scriptc-pm") + ) { + process.stderr.write( + "spm: warning: .scriptc-pm/ is not in .gitignore (overlays may contain private source)\n", + ); + } +} diff --git a/packages/pm/src/tokens.ts b/packages/pm/src/tokens.ts new file mode 100644 index 0000000000..ee66c996d6 --- /dev/null +++ b/packages/pm/src/tokens.ts @@ -0,0 +1,502 @@ +// A small JavaScript/TypeScript tokenizer and the scanners built on it: +// token-normalized comparison of emitted JS (DESIGN.md §6.3 "equivalent"), +// import specifier extraction (missing-module detection), and exported-name +// collection (the declaration surface check, §6.4). +// +// It is deliberately approximate: regex literals are recognized by the +// previous significant token, and template literals are kept as raw chunks. +// Both sides of every comparison go through the same tokenizer. + +export type TokenKind = "id" | "num" | "str" | "tpl" | "re" | "punct"; + +export interface Token { + kind: TokenKind; + value: string; +} + +const REGEX_AFTER_KEYWORD = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "throw", + "case", + "do", + "else", + "yield", + "await", +]); + +const PUNCTUATORS = [ + ">>>=", + "...", + "===", + "!==", + "**=", + "<<=", + ">>=", + ">>>", + "&&=", + "||=", + "??=", + "=>", + "==", + "!=", + "<=", + ">=", + "&&", + "||", + "??", + "?.", + "++", + "--", + "+=", + "-=", + "*=", + "/=", + "%=", + "&=", + "|=", + "^=", + "**", + "<<", + ">>", +]; + +function isIdStart(c: string): boolean { + return /[A-Za-z_$\u0080-￿]/.test(c); +} + +function isIdPart(c: string): boolean { + return /[A-Za-z0-9_$\u0080-￿]/.test(c); +} + +function regexAllowed(prev: Token | undefined): boolean { + if (prev === undefined) return true; + if (prev.kind === "punct") return prev.value !== ")" && prev.value !== "]" && prev.value !== "}"; + if (prev.kind === "id") return REGEX_AFTER_KEYWORD.has(prev.value); + return false; +} + +export function tokenize(src: string): Token[] { + const tokens: Token[] = []; + // Brace depths at which an open template literal resumes. + const templateStack: number[] = []; + let braceDepth = 0; + let i = 0; + const n = src.length; + const scanTemplate = (start: number): number => { + // `start` is just after "`" or "}"; returns the index after the chunk. + let j = start; + while (j < n) { + const c = src[j] as string; + if (c === "\\") { + j += 2; + } else if (c === "`") { + tokens.push({ kind: "tpl", value: src.slice(start, j) }); + return j + 1; + } else if (c === "$" && src[j + 1] === "{") { + tokens.push({ kind: "tpl", value: `${src.slice(start, j)}\${` }); + templateStack.push(braceDepth); + braceDepth += 1; + return j + 2; + } else { + j += 1; + } + } + tokens.push({ kind: "tpl", value: src.slice(start) }); + return n; + }; + while (i < n) { + const c = src[i] as string; + if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "" || c === " ") { + i += 1; + continue; + } + if (c === "/" && src[i + 1] === "/") { + while (i < n && src[i] !== "\n") i += 1; + continue; + } + if (c === "/" && src[i + 1] === "*") { + const end = src.indexOf("*/", i + 2); + i = end < 0 ? n : end + 2; + continue; + } + if (c === "#" && i === 0 && src[1] === "!") { + while (i < n && src[i] !== "\n") i += 1; + continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < n && src[j] !== c && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1; + tokens.push({ kind: "str", value: src.slice(i + 1, j) }); + i = j + 1; + continue; + } + if (c === "`") { + i = scanTemplate(i + 1); + continue; + } + if (isIdStart(c) || (c === "#" && i + 1 < n && isIdStart(src[i + 1] as string))) { + let j = i + 1; + while (j < n && isIdPart(src[j] as string)) j += 1; + tokens.push({ kind: "id", value: src.slice(i, j) }); + i = j; + continue; + } + if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) { + let j = i + 1; + while (j < n && /[0-9A-Za-z_.]/.test(src[j] as string)) { + if ((src[j] === "e" || src[j] === "E") && (src[j + 1] === "+" || src[j + 1] === "-")) { + j += 2; + } else { + j += 1; + } + } + tokens.push({ kind: "num", value: src.slice(i, j).replace(/_/g, "") }); + i = j; + continue; + } + if (c === "/" && regexAllowed(tokens[tokens.length - 1])) { + let j = i + 1; + let inClass = false; + while (j < n && src[j] !== "\n") { + const d = src[j] as string; + if (d === "\\") { + j += 2; + continue; + } + if (d === "[") inClass = true; + else if (d === "]") inClass = false; + else if (d === "/" && !inClass) break; + j += 1; + } + j += 1; + while (j < n && isIdPart(src[j] as string)) j += 1; + tokens.push({ kind: "re", value: src.slice(i, j) }); + i = j; + continue; + } + if (c === "{") { + braceDepth += 1; + tokens.push({ kind: "punct", value: c }); + i += 1; + continue; + } + if (c === "}") { + braceDepth -= 1; + const top = templateStack[templateStack.length - 1]; + if (top !== undefined && top === braceDepth) { + templateStack.pop(); + i = scanTemplate(i + 1); + continue; + } + tokens.push({ kind: "punct", value: c }); + i += 1; + continue; + } + let matched = c; + for (const p of PUNCTUATORS) { + if (src.startsWith(p, i)) { + matched = p; + break; + } + } + tokens.push({ kind: "punct", value: matched }); + i += matched.length; + } + return tokens; +} + +// ── normalization for emitted-JS comparison ───────────────────────────── + +function isPunct(t: Token | undefined, value: string): boolean { + return t !== undefined && t.kind === "punct" && t.value === value; +} + +function isId(t: Token | undefined, value: string): boolean { + return t !== undefined && t.kind === "id" && t.value === value; +} + +/** Index just past the statement starting at `start` (its `;` at depth 0). */ +function statementEnd(tokens: Token[], start: number): number { + let depth = 0; + for (let i = start; i < tokens.length; i += 1) { + const t = tokens[i] as Token; + if (t.kind !== "punct") continue; + if (t.value === "(" || t.value === "[" || t.value === "{") depth += 1; + else if (t.value === ")" || t.value === "]" || t.value === "}") depth -= 1; + else if (t.value === ";" && depth === 0) return i + 1; + } + return tokens.length; +} + +/** Remove emitter boilerplate that varies between TypeScript versions and + * build tools but carries no program behavior: the "use strict" prologue, + * helper definitions (`var __importDefault = ...`), the `__esModule` marker, + * `exports.a = exports.b = void 0` pre-declarations, the `(0, ns.f)(...)` + * call form, quote style, semicolons and trailing commas. */ +export function normalizeEmit(input: Token[]): Token[] { + const out: Token[] = []; + // `exports.f = f;` for exported function declarations moved from after the + // declaration to the module top between TypeScript versions; these are + // compared as a sorted set at the end instead of in place. + const exportAssignments: string[] = []; + let i = 0; + let depth = 0; + while (i < input.length) { + const t = input[i] as Token; + if (depth === 0) { + if ( + isId(t, "exports") && + isPunct(input[i + 1], ".") && + input[i + 2]?.kind === "id" && + isPunct(input[i + 3], "=") && + input[i + 4]?.kind === "id" && + isPunct(input[i + 5], ";") + ) { + exportAssignments.push(`${(input[i + 2] as Token).value}=${(input[i + 4] as Token).value}`); + i += 6; + continue; + } + if (t.kind === "str" && t.value === "use strict" && isPunct(input[i + 1], ";")) { + i += 2; + continue; + } + if ( + (isId(t, "var") || isId(t, "const") || isId(t, "let")) && + input[i + 1]?.kind === "id" && + (input[i + 1] as Token).value.startsWith("__") && + isPunct(input[i + 2], "=") + ) { + i = statementEnd(input, i); + continue; + } + if ( + isId(t, "Object") && + isPunct(input[i + 1], ".") && + isId(input[i + 2], "defineProperty") && + isPunct(input[i + 3], "(") && + isId(input[i + 4], "exports") && + input[i + 6]?.kind === "str" && + (input[i + 6] as Token).value === "__esModule" + ) { + i = statementEnd(input, i); + continue; + } + if (isId(t, "exports") && isVoidPredeclaration(input, i)) { + i = statementEnd(input, i); + continue; + } + } + // (0, a.b)( → a.b( + if (isPunct(t, "(") && input[i + 1]?.kind === "num" && (input[i + 1] as Token).value === "0") { + if (isPunct(input[i + 2], ",")) { + let j = i + 3; + while (input[j]?.kind === "id" && isPunct(input[j + 1], ".")) j += 2; + if (input[j]?.kind === "id" && isPunct(input[j + 1], ")") && j > i + 3) { + for (let k = i + 3; k <= j; k += 1) out.push(input[k] as Token); + i = j + 2; + continue; + } + } + } + if (t.kind === "punct") { + if (t.value === "(" || t.value === "[" || t.value === "{") depth += 1; + else if (t.value === ")" || t.value === "]" || t.value === "}") depth -= 1; + if (t.value === ";") { + i += 1; + continue; + } + if (t.value === ",") { + const nextTok = input[i + 1]; + if (isPunct(nextTok, ")") || isPunct(nextTok, "]") || isPunct(nextTok, "}")) { + i += 1; + continue; + } + } + } + if (t.kind === "str") { + out.push({ kind: "str", value: t.value.replace(/\\(['"])/g, "$1") }); + } else { + out.push(t); + } + i += 1; + } + exportAssignments.sort(); + for (const assignment of exportAssignments) + out.push({ kind: "punct", value: `exports:${assignment}` }); + return out; +} + +function isVoidPredeclaration(tokens: Token[], start: number): boolean { + let i = start; + while (isId(tokens[i], "exports") && isPunct(tokens[i + 1], ".") && isPunct(tokens[i + 3], "=")) { + i += 4; + } + return i > start && isId(tokens[i], "void") && tokens[i + 1]?.value === "0"; +} + +export interface Difference { + index: number; + expected: string; + actual: string; +} + +/** First difference between two normalized token streams, or null. */ +export function firstDifference(published: Token[], emitted: Token[]): Difference | null { + const n = Math.max(published.length, emitted.length); + for (let i = 0; i < n; i += 1) { + const a = published[i]; + const b = emitted[i]; + if (a !== undefined && b !== undefined && a.kind === b.kind && a.value === b.value) continue; + return { index: i, expected: context(published, i), actual: context(emitted, i) }; + } + return null; +} + +function context(tokens: Token[], at: number): string { + return tokens + .slice(Math.max(0, at - 4), at + 6) + .map((t) => t.value) + .join(" "); +} + +// ── imports ────────────────────────────────────────────────────────────── + +export interface ImportRef { + specifier: string; + typeOnly: boolean; +} + +/** Module specifiers of import/export-from statements, `import("x")`, + * `require("x")` and `import x = require("x")`. */ +export function importsOf(tokens: Token[]): ImportRef[] { + const out: ImportRef[] = []; + let typeOnly = false; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i] as Token; + if (t.kind !== "id") continue; + const prev = tokens[i - 1]; + const memberAccess = isPunct(prev, ".") || isPunct(prev, "?."); + if ((t.value === "import" || t.value === "export") && !memberAccess) { + const nextTok = tokens[i + 1]; + typeOnly = isId(nextTok, "type") && !isPunct(tokens[i + 2], "("); + if (t.value === "import" && nextTok?.kind === "str") { + out.push({ specifier: nextTok.value, typeOnly: false }); + } else if (t.value === "import" && isPunct(nextTok, "(") && tokens[i + 2]?.kind === "str") { + out.push({ specifier: (tokens[i + 2] as Token).value, typeOnly: false }); + } + continue; + } + if (t.value === "from" && tokens[i + 1]?.kind === "str") { + out.push({ specifier: (tokens[i + 1] as Token).value, typeOnly: typeOnly }); + typeOnly = false; + continue; + } + if ( + t.value === "require" && + !memberAccess && + isPunct(tokens[i + 1], "(") && + tokens[i + 2]?.kind === "str" && + isPunct(tokens[i + 3], ")") + ) { + out.push({ specifier: (tokens[i + 2] as Token).value, typeOnly: false }); + } + } + return out; +} + +// ── exported names ─────────────────────────────────────────────────────── + +const DECL_KEYWORDS = new Set([ + "function", + "class", + "const", + "let", + "var", + "enum", + "namespace", + "module", + "interface", + "type", +]); +const DECL_MODIFIERS = new Set(["declare", "abstract", "async", "default"]); + +export interface ExportScan { + names: string[]; + /** Specifiers of `export * from "x"` statements, to be followed. */ + stars: string[]; +} + +/** Exported names declared at the top level of one module. */ +export function exportsOf(tokens: Token[]): ExportScan { + const names: string[] = []; + const stars: string[] = []; + let depth = 0; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i] as Token; + if (t.kind === "punct") { + if (t.value === "{") depth += 1; + else if (t.value === "}") depth -= 1; + continue; + } + if (depth !== 0 || !isId(t, "export")) continue; + if (isPunct(tokens[i - 1], ".")) continue; + let j = i + 1; + const first = tokens[j]; + if (isPunct(first, "=")) { + names.push("export="); + continue; + } + if (isId(first, "as") && isId(tokens[j + 1], "namespace")) continue; + if (isId(first, "default")) { + names.push("default"); + continue; + } + if (isId(first, "type") && isPunct(tokens[j + 1], "{")) j += 1; + if (isId(first, "import") && tokens[j + 1]?.kind === "id") { + names.push((tokens[j + 1] as Token).value); + continue; + } + if (isPunct(tokens[j], "*")) { + if (isId(tokens[j + 1], "as") && tokens[j + 2]?.kind === "id") { + names.push((tokens[j + 2] as Token).value); + } else if (isId(tokens[j + 1], "from") && tokens[j + 2]?.kind === "str") { + stars.push((tokens[j + 2] as Token).value); + } + continue; + } + if (isPunct(tokens[j], "{")) { + let k = j + 1; + while (k < tokens.length && !isPunct(tokens[k], "}")) { + const tok = tokens[k] as Token; + if (tok.kind === "id" && tok.value !== "type") { + const after = tokens[k + 1]; + if (isId(after, "as") && tokens[k + 2] !== undefined) { + names.push((tokens[k + 2] as Token).value); + k += 3; + continue; + } + names.push(tok.value); + } + k += 1; + } + continue; + } + while (tokens[j]?.kind === "id" && DECL_MODIFIERS.has((tokens[j] as Token).value)) j += 1; + if (isId(tokens[j], "const") && isId(tokens[j + 1], "enum")) j += 1; + const keyword = tokens[j]; + if (keyword?.kind === "id" && DECL_KEYWORDS.has(keyword.value)) { + let k = j + 1; + if (isPunct(tokens[k], "*")) k += 1; + const nameTok = tokens[k]; + if (nameTok?.kind === "id") names.push(nameTok.value); + } + } + return { names: names, stars: stars }; +} diff --git a/packages/pm/src/tree.ts b/packages/pm/src/tree.ts new file mode 100644 index 0000000000..50586b8869 --- /dev/null +++ b/packages/pm/src/tree.ts @@ -0,0 +1,209 @@ +// Working inside a fetched source tree: locating the package directory +// (DESIGN.md §5.3.1) and computing the effective tsconfig (§5.3.2) with the +// semantic options the compiler cares about (§6.5). + +import { dirname, join, relative, resolve } from "node:path"; +import { isDir, isFile, readText, stripJsonc, walkFiles } from "./util.js"; + +const SKIP_DIRS = new Set(["node_modules", ".git"]); + +interface NamedPackage { + name?: string; + version?: string; +} + +/** The directory (relative to `root`, "" for the root) whose package.json + * names `name`. `hint` (repository.directory) wins when it matches; a + * matching version breaks ties. */ +export function locatePackageDir( + root: string, + name: string, + version: string, + hint: string, +): string | null { + const nameAt = (rel: string): NamedPackage | null => { + const text = readText(join(root, rel, "package.json")); + if (text === null) return null; + try { + return JSON.parse(text) as NamedPackage; + } catch { + return null; + } + }; + const cleanHint = hint.replace(/^\.?\//, "").replace(/\/$/, ""); + if (cleanHint !== "" && nameAt(cleanHint)?.name === name) return cleanHint; + let best: string | null = null; + for (const file of walkFiles(root, SKIP_DIRS)) { + if (file !== "package.json" && !file.endsWith("/package.json")) continue; + const rel = file === "package.json" ? "" : file.slice(0, -"/package.json".length); + const pkg = nameAt(rel); + if (pkg === null || pkg.name !== name) continue; + if (pkg.version === version) return rel; + // Prefer the shallowest match among version mismatches (uncommitted bumps). + if (best === null || rel.split("/").length < best.split("/").length) best = rel; + } + return best; +} + +export interface TsOptions { + /** Raw compilerOptions, merged along the `extends` chain. */ + options: Record; + /** `include` patterns, relative to the package dir. */ + include: string[]; + /** True when an `extends` target could not be read (npm presets). */ + partial: boolean; + /** The tsconfig file used, relative to the package dir. */ + file: string; +} + +function readTsconfig(path: string): Record | null { + const text = readText(path); + if (text === null) return null; + try { + return JSON.parse(stripJsonc(text)) as Record; + } catch { + return null; + } +} + +/** The effective tsconfig of the package directory, following `extends` + * within the tree. Paths outside `treeRoot` are not read. */ +export function effectiveTsconfig(treeRoot: string, pkgDir: string): TsOptions { + const result: TsOptions = { options: {}, include: [], partial: false, file: "" }; + let file = ""; + for (const candidate of ["tsconfig.build.json", "tsconfig.lib.json", "tsconfig.json"]) { + if (isFile(join(pkgDir, candidate))) { + file = candidate; + break; + } + } + if (file === "") return result; + result.file = file; + const chain: Record[] = []; + let includeFrom = ""; + let current = join(pkgDir, file); + const root = resolve(treeRoot); + for (let depth = 0; depth < 8; depth += 1) { + const config = readTsconfig(current); + if (config === null) { + result.partial = true; + break; + } + chain.push(config); + if (includeFrom === "" && Array.isArray(config["include"])) { + includeFrom = dirname(current); + for (const pattern of config["include"] as unknown[]) { + if (typeof pattern === "string") { + result.include.push(relative(pkgDir, join(includeFrom, pattern))); + } + } + } + let ext = config["extends"]; + if (Array.isArray(ext)) { + // Array-form extends: later entries win; the POC follows the last one. + const items = ext as unknown[]; + ext = items.length > 0 ? items[items.length - 1] : undefined; + } + if (typeof ext !== "string") break; + if (!ext.startsWith(".")) { + // An npm preset (@tsconfig/*): not resolved in the POC. + result.partial = true; + break; + } + let next = resolve(dirname(current), ext); + if (!next.endsWith(".json")) next = `${next}.json`; + if (!next.startsWith(root)) { + result.partial = true; + break; + } + current = next; + } + for (let i = chain.length - 1; i >= 0; i -= 1) { + const opts = (chain[i] as Record)["compilerOptions"]; + if (typeof opts !== "object" || opts === null) continue; + const record = opts as Record; + for (const key of Object.keys(record)) result.options[key] = record[key]; + } + return result; +} + +export function optionString(ts: TsOptions, key: string): string { + const value = ts.options[key]; + return typeof value === "string" ? value.toLowerCase() : ""; +} + +export function optionBool(ts: TsOptions, key: string): boolean | null { + const value = ts.options[key]; + return typeof value === "boolean" ? value : null; +} + +const TARGET_ORDER = [ + "es3", + "es5", + "es6", + "es2015", + "es2016", + "es2017", + "es2018", + "es2019", + "es2020", + "es2021", + "es2022", + "es2023", + "es2024", + "esnext", +]; + +/** `useDefineForClassFields`, explicit or derived from the target. */ +export function useDefineForClassFields(ts: TsOptions): boolean { + const explicit = optionBool(ts, "useDefineForClassFields"); + if (explicit !== null) return explicit; + const target = optionString(ts, "target"); + if (target === "") return true; + return TARGET_ORDER.indexOf(target) >= TARGET_ORDER.indexOf("es2022"); +} + +/** Source files (relative to the package dir) the build compiles: the + * tsconfig `include` directories, else `src/`, else top-level files. Tests + * and declaration files are left out. */ +export function sourceFilesOf(pkgDir: string, ts: TsOptions): string[] { + const roots: string[] = []; + for (const pattern of ts.include) { + let base = pattern.split("/*")[0] ?? ""; + if (base.includes("*")) base = ""; + if (base.startsWith("..")) continue; + if (/\.[cm]?tsx?$/.test(base)) roots.push(base); + else if (!roots.includes(base)) roots.push(base); + } + if (roots.length === 0) roots.push(isDir(join(pkgDir, "src")) ? "src" : ""); + const out = new Set(); + for (const root of roots) { + if (root !== "" && isFile(join(pkgDir, root))) { + if (isSourceFile(root)) out.add(root); + continue; + } + const abs = root === "" ? pkgDir : join(pkgDir, root); + if (!isDir(abs)) continue; + const skip = new Set([ + "node_modules", + ".git", + "dist", + "__tests__", + "__mocks__", + "test", + "tests", + "fixtures", + ]); + for (const file of walkFiles(abs, skip)) { + const rel = root === "" ? file : `${root}/${file}`; + if (root === "" && rel.includes("/")) continue; + if (isSourceFile(rel)) out.add(rel); + } + } + return [...out].sort(); +} + +export function isSourceFile(rel: string): boolean { + if (!/\.[cm]?tsx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return false; + return !/\.(test|spec|bench)\.[cm]?tsx?$/.test(rel); +} diff --git a/packages/pm/src/util.ts b/packages/pm/src/util.ts new file mode 100644 index 0000000000..da5dbe14bc --- /dev/null +++ b/packages/pm/src/util.ts @@ -0,0 +1,212 @@ +// Small shared helpers: filesystem probes, hashing, subprocesses. +// +// Everything here must compile natively with scriptc, so the code sticks to the +// Node surface that scriptc lowers today: sync fs, `spawn` with Buffer chunks, +// `createHash`, and plain records. + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +export function isDir(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + +export function isSymlink(path: string): boolean { + try { + return lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +export function readText(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** Every regular file under `root`, as sorted `/`-separated relative paths. + * Directories named in `skip` are not entered; symlinks are not followed. */ +export function walkFiles(root: string, skip: ReadonlySet): string[] { + const out: string[] = []; + const stack: string[] = [""]; + while (stack.length > 0) { + const rel = stack.pop() ?? ""; + const abs = rel === "" ? root : join(root, rel); + let names: string[]; + try { + names = readdirSync(abs); + } catch { + continue; + } + for (const name of names) { + const childRel = rel === "" ? name : `${rel}/${name}`; + const childAbs = join(root, childRel); + if (isSymlink(childAbs)) continue; + if (isDir(childAbs)) { + if (!skip.has(name)) stack.push(childRel); + } else if (isFile(childAbs)) { + out.push(childRel); + } + } + } + out.sort(); + return out; +} + +export function copyTree(from: string, to: string, files: readonly string[]): void { + for (const rel of files) { + const dest = join(to, rel); + const slash = dest.lastIndexOf("/"); + mkdirSync(dest.slice(0, slash), { recursive: true }); + copyFileSync(join(from, rel), dest); + } +} + +export function sha256Hex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +/** git's blob id for file bytes: sha1("blob \0" + bytes). */ +export function gitBlobSha1(bytes: Buffer): string { + const h = createHash("sha1"); + h.update(`blob ${bytes.length}\0`); + h.update(bytes); + return h.digest("hex"); +} + +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +/** Run a command to completion with piped output. Never throws for a nonzero + * exit; a spawn failure (missing binary) is reported as code 127. */ +export function run( + cmd: string, + args: string[], + cwd: string, + env: Record, +): Promise { + return new Promise((resolvePromise) => { + const out: Buffer[] = []; + const err: Buffer[] = []; + let settled = false; + const child = spawn(cmd, args, { cwd: cwd, env: env, stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (chunk: Buffer) => { + out.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + err.push(chunk); + }); + child.on("error", (error: Error) => { + if (settled) return; + settled = true; + resolvePromise({ code: 127, stdout: "", stderr: error.message }); + }); + child.on("close", (code: number | null) => { + if (settled) return; + settled = true; + resolvePromise({ + code: code ?? 1, + stdout: Buffer.concat(out).toString("utf8"), + stderr: Buffer.concat(err).toString("utf8"), + }); + }); + }); +} + +/** Run a command with the terminal attached (used for pnpm itself). */ +export function runInherit(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + let settled = false; + const child = spawn(cmd, args, { cwd: cwd, stdio: "inherit" }); + child.on("error", () => { + if (settled) return; + settled = true; + resolvePromise(127); + }); + child.on("close", (code: number | null) => { + if (settled) return; + settled = true; + resolvePromise(code ?? 1); + }); + }); +} + +/** A copy of the process environment as a plain string record. */ +export function baseEnv(): Record { + const env: Record = {}; + for (const key of Object.keys(process.env)) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return env; +} + +/** Run `tasks` with at most `limit` in flight; results keep input order. */ +export async function pool( + items: readonly T[], + limit: number, + task: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await task(items[index] as T); + } + }; + const workers: Promise[] = []; + for (let i = 0; i < Math.min(limit, items.length); i += 1) workers.push(worker()); + await Promise.all(workers); + return results; +} + +/** Strip comments and trailing commas from JSONC (tsconfig) text. */ +export function stripJsonc(text: string): string { + let out = ""; + let i = 0; + while (i < text.length) { + const c = text[i] as string; + if (c === '"') { + let j = i + 1; + while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1; + out += text.slice(i, j + 1); + i = j + 1; + } else if (c === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i += 1; + } else if (c === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + i = end < 0 ? text.length : end + 2; + } else { + out += c; + i += 1; + } + } + return out.replace(/,(\s*[}\]])/g, "$1"); +} diff --git a/packages/pm/src/verify.ts b/packages/pm/src/verify.ts new file mode 100644 index 0000000000..fb8e36c9e1 --- /dev/null +++ b/packages/pm/src/verify.ts @@ -0,0 +1,564 @@ +// The acceptance gate (DESIGN.md §6): does the candidate source correspond to +// the published artifact the user already runs under Node? +// +// - exact (§6.3.1): shipped `.ts` files and shipped `sourcesContent` are +// byte-equal to the candidate tree. +// - equivalent (§6.3.2): the candidate is re-emitted with the TypeScript +// compiler found in the environment and compared, per module, with the +// published JS after token normalization. +// - structural (§6.3.3): for bundles without maps, per-module literal and +// member-name multisets match the bundle's `// src/x.ts` segments. +// - surface (§6.4): exported names of the source entry equal those of the +// published declaration entry. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, normalize } from "node:path"; +import type { ContentLevel, SurfaceLevel } from "./manifest.js"; +import type { Published } from "./published.js"; +import { typesEntry } from "./published.js"; +import type { Token } from "./tokens.js"; +import { exportsOf, firstDifference, normalizeEmit, tokenize } from "./tokens.js"; +import type { TsOptions } from "./tree.js"; +import { optionString } from "./tree.js"; +import { baseEnv, isFile, readText, run, walkFiles } from "./util.js"; + +export interface ContentResult { + level: ContentLevel; + /** Checks that ran, for the lock (e.g. "sourcesContent 12/12"). */ + checks: string[]; + detail: string; + /** Published JS file → source file (package-relative), when known. */ + pubToSrc: Map; +} + +const DEBUG = process.env["SPM_DEBUG"] === "1"; +const TS_SOURCE = /\.([cm]?ts|tsx)$/; +const DECLARATION = /\.d\.[cm]?ts$/; + +// ── exact ──────────────────────────────────────────────────────────────── + +/** Byte-compare shipped TS (files and map sourcesContent) with `srcDir`. + * Returns null when the tarball carries no TS to compare. */ +export function exactCheck(pub: Published, srcDir: string): ContentResult | null { + let total = 0; + let equal = 0; + let firstMismatch = ""; + const pubToSrc = new Map(); + const compare = (rel: string, content: string): void => { + total += 1; + const actual = readText(join(srcDir, rel)); + if (actual === content) equal += 1; + else if (firstMismatch === "") + firstMismatch = actual === null ? `${rel} missing` : `${rel} differs`; + }; + for (const map of pub.maps) { + const tsSources: string[] = []; + for (let i = 0; i < map.sources.length; i += 1) { + const source = map.sources[i] as string; + const content = map.contents[i] ?? null; + if (!TS_SOURCE.test(source) || DECLARATION.test(source)) continue; + if (source.startsWith("..") || source.includes("node_modules/")) continue; + tsSources.push(source); + if (content !== null) compare(source, content); + } + if (tsSources.length === 1) pubToSrc.set(map.file, tsSources[0] as string); + } + const mapsTotal = total; + for (const file of pub.files) { + if (!TS_SOURCE.test(file) || DECLARATION.test(file)) continue; + const content = readText(join(pub.dir, file)); + if (content !== null) compare(file, content); + } + if (total === 0) return null; + const checks = [ + `sourcesContent ${mapsTotal === 0 ? "n/a" : "checked"}`, + `exact ${equal}/${total}`, + ]; + if (equal === total) return { level: "exact", checks: checks, detail: "", pubToSrc: pubToSrc }; + return { level: "mismatch", checks: checks, detail: firstMismatch, pubToSrc: pubToSrc }; +} + +// ── equivalent ─────────────────────────────────────────────────────────── + +export interface Emitter { + /** Command and leading args that run `tsc`. */ + command: string; + prefix: string[]; +} + +export function findTsc(projectDir: string): Emitter | null { + const explicit = process.env["SPM_TSC"]; + if (explicit !== undefined && explicit !== "") { + return explicit.endsWith(".js") + ? { command: "node", prefix: [explicit] } + : { command: explicit, prefix: [] }; + } + const local = join(projectDir, "node_modules", "typescript", "bin", "tsc"); + if (isFile(local)) return { command: "node", prefix: [local] }; + return { command: "tsc", prefix: [] }; +} + +const PASSTHROUGH = [ + "importHelpers", + "esModuleInterop", + "allowSyntheticDefaultImports", + "useDefineForClassFields", + "experimentalDecorators", + "emitDecoratorMetadata", + "jsx", + "jsxFactory", + "jsxFragmentFactory", + "jsxImportSource", + "downlevelIteration", + "preserveConstEnums", + "verbatimModuleSyntax", + "noEmitHelpers", +]; + +const FALLBACK_TARGETS = ["es2019", "es2017", "es2015", "es5", "es2020", "es2022", "esnext"]; + +interface EmitRun { + files: Map; + error: string; +} + +async function emit( + tsc: Emitter, + srcDir: string, + files: string[], + ts: TsOptions, + moduleKind: string, + target: string, +): Promise { + const tmp = mkdtempSync(join(tmpdir(), "spm-emit-")); + try { + const options: Record = {}; + for (const key of PASSTHROUGH) { + const value = ts.options[key]; + if (value !== undefined) options[key] = value; + } + options["target"] = target; + options["module"] = moduleKind; + if (moduleKind === "commonjs") options["moduleResolution"] = "node10"; + else if (moduleKind === "node16" || moduleKind === "nodenext") + options["moduleResolution"] = moduleKind; + else options["moduleResolution"] = "bundler"; + options["rootDir"] = srcDir; + options["outDir"] = join(tmp, "out"); + options["noCheck"] = true; + options["noResolve"] = true; + options["noEmitOnError"] = false; + options["declaration"] = false; + options["sourceMap"] = false; + options["skipLibCheck"] = true; + options["allowImportingTsExtensions"] = false; + // Harmless when the source uses .js specifiers; required when it uses .ts + // specifiers and the tsconfig that enabled it was not readable. + options["rewriteRelativeImportExtensions"] = true; + options["types"] = []; + options["ignoreDeprecations"] = "5.0"; + const config = { compilerOptions: options, files: files.map((f) => join(srcDir, f)) }; + writeFileSync(join(tmp, "tsconfig.json"), JSON.stringify(config)); + const res = await run( + tsc.command, + [...tsc.prefix, "-p", join(tmp, "tsconfig.json")], + tmp, + baseEnv(), + ); + const out = new Map(); + const outDir = join(tmp, "out"); + for (const rel of walkFiles(outDir, new Set())) { + const text = readText(join(outDir, rel)); + if (text !== null && /\.[cm]?js$/.test(rel)) out.set(rel, text); + } + // tsc exits nonzero for diagnostics even with noCheck; only a run that + // emitted nothing is a failure. + const error = + out.size === 0 ? ((res.stdout + res.stderr).trim().split("\n")[0] ?? "no output") : ""; + return { files: out, error: error }; + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +function normExt(rel: string): string { + return rel.replace(/\.[cm]?js$/, ".js"); +} + +function prefixIndex(paths: string[], maxDepth: number): Map> { + const index = new Map>(); + for (const path of paths) { + const segs = path.split("/"); + for (let m = 0; m <= Math.min(maxDepth, segs.length - 1); m += 1) { + const prefix = segs.slice(0, m).join("/"); + let bucket = index.get(prefix); + if (bucket === undefined) { + bucket = new Map(); + index.set(prefix, bucket); + } + bucket.set(normExt(segs.slice(m).join("/")), path); + } + } + return index; +} + +const BUNDLE_MARKER = /^\/\/(?:#region)? ([^\n]+\.[cm]?tsx?)\s*$/gm; + +function isBundle(text: string): boolean { + const re = new RegExp(BUNDLE_MARKER.source, "gm"); + return re.exec(text) !== null && re.exec(text) !== null; +} + +interface Pairing { + pairs: [string, string][]; + equal: number; + firstDiff: string; +} + +function comparePairs(emitted: Map, pub: Published, bundles: Set): Pairing { + const candidates = pub.jsFiles.filter((f) => !bundles.has(f)); + const pubIndex = prefixIndex(candidates, 3); + const emitIndex = prefixIndex([...emitted.keys()], 2); + let best: Pairing = { pairs: [], equal: 0, firstDiff: "" }; + for (const emitBucket of emitIndex.values()) { + for (const pubBucket of pubIndex.values()) { + const pairs: [string, string][] = []; + for (const [rest, emittedPath] of emitBucket) { + const pubPath = pubBucket.get(rest); + if (pubPath !== undefined) pairs.push([pubPath, emittedPath]); + } + if (pairs.length === 0 || pairs.length < best.pairs.length) continue; + let equal = 0; + let firstDiff = ""; + for (const [pubPath, emittedPath] of pairs) { + const published = normalizeEmit(tokenize(readText(join(pub.dir, pubPath)) ?? "")); + const ours = normalizeEmit(tokenize(emitted.get(emittedPath) ?? "")); + const diff = firstDifference(published, ours); + if (diff === null) equal += 1; + else if (firstDiff === "") { + firstDiff = `${pubPath}: published «${diff.expected}» vs source «${diff.actual}»`; + } + } + if (pairs.length > best.pairs.length || equal > best.equal) { + best = { pairs: pairs, equal: equal, firstDiff: firstDiff }; + } + } + } + return best; +} + +/** Multiset fingerprint: string/template literals and member names, in + * sorted order. Numeric literals are left out: esbuild constant-folds enum + * initializers (`1 << 16`) and inlines enum members, tsc does not. */ +function fingerprint(tokens: Token[]): string { + const items: string[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i] as Token; + if (t.kind === "str" || t.kind === "tpl") items.push(`${t.kind}:${t.value}`); + else if (t.kind === "id" && i > 0 && tokens[i - 1]?.value === ".") items.push(`.${t.value}`); + } + items.sort(); + return items.join("\n"); +} + +/** Replace `E.Member` with its value for enums declared in the module + * (`E[E["Member"] = 1] = "Member"`): esbuild inlines local enum members, + * tsc does not. */ +function inlineEnums(tokens: Token[]): Token[] { + const values = new Map(); + for (let i = 0; i + 8 < tokens.length; i += 1) { + const t = tokens; + if ( + t[i]?.kind === "id" && + t[i + 1]?.value === "[" && + t[i + 2]?.value === t[i]?.value && + t[i + 3]?.value === "[" && + t[i + 4]?.kind === "str" && + t[i + 5]?.value === "]" && + t[i + 6]?.value === "=" && + t[i + 7]?.kind === "num" + ) { + values.set( + `${(t[i] as Token).value}.${(t[i + 4] as Token).value}`, + (t[i + 7] as Token).value, + ); + } + } + if (values.size === 0) return tokens; + const out: Token[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i] as Token; + // Drop the member definitions themselves: esbuild removes an unexported + // enum object once every use is inlined. + if ( + t.kind === "id" && + tokens[i + 1]?.value === "[" && + tokens[i + 2]?.value === t.value && + tokens[i + 3]?.value === "[" && + tokens[i + 4]?.kind === "str" && + tokens[i + 5]?.value === "]" && + tokens[i + 6]?.value === "=" + ) { + // E[E["A"] = ] = "A": skip to the closing "]" and the name. + let j = i + 7; + let depth = 1; + while (j < tokens.length && depth > 0) { + const v = (tokens[j] as Token).value; + if (v === "[" || v === "(") depth += 1; + else if (v === "]" || v === ")") depth -= 1; + j += 1; + } + if (tokens[j]?.value === "=" && tokens[j + 1]?.kind === "str") { + i = j + 1; + continue; + } + } + const member = tokens[i + 2]; + if ( + t.kind === "id" && + tokens[i + 1]?.value === "." && + member?.kind === "id" && + tokens[i - 1]?.value !== "." + ) { + const value = values.get(`${t.value}.${member.value}`); + if (value !== undefined) { + out.push({ kind: "num", value: value }); + i += 2; + continue; + } + } + out.push(t); + } + return out; +} + +/** Drop module specifier strings: a bundle hoists and dedupes imports. */ +function withoutSpecifiers(tokens: Token[]): Token[] { + return tokens.filter((t, i) => { + if (t.kind !== "str") return true; + const prev = tokens[i - 1]; + const prev2 = tokens[i - 2]; + if (prev?.value === "from" || prev?.value === "import") return false; + return !(prev?.value === "(" && (prev2?.value === "require" || prev2?.value === "import")); + }); +} + +function multisetDiff(a: string, b: string): string { + const count = (text: string): Map => { + const m = new Map(); + for (const item of text.split("\n")) m.set(item, (m.get(item) ?? 0) + 1); + return m; + }; + const ca = count(a); + const cb = count(b); + const out: string[] = []; + for (const [k, v] of ca) + if ((cb.get(k) ?? 0) !== v) out.push(`${k} bundle=${v} source=${cb.get(k) ?? 0}`); + for (const [k, v] of cb) if (!ca.has(k)) out.push(`${k} bundle=0 source=${v}`); + return out.slice(0, 8).join(" | "); +} + +/** Whole-bundle structural comparison. esbuild does not mark every module + * boundary (`// src/x.ts` markers are missing for some modules), so the + * bundle is compared as one multiset against the union of all emitted + * source modules. Imports are hoisted and deduped by the + * bundler, so module specifiers are left out; local enum members are + * inlined on both sides. */ +function structuralCheck( + bundleText: string, + emitted: Map, +): { ok: number; total: number; detail: string } { + let union = ""; + let total = 0; + for (const text of emitted.values()) { + total += 1; + union += `\n;${text}`; + } + if (total === 0) return { ok: 0, total: 0, detail: "no bundle module matched a source module" }; + const a = fingerprint(withoutSpecifiers(inlineEnums(normalizeEmit(tokenize(bundleText))))); + const b = fingerprint(withoutSpecifiers(inlineEnums(normalizeEmit(tokenize(union))))); + if (a === b) return { ok: total, total: total, detail: "" }; + if (DEBUG) process.stderr.write(`spm-debug structural: ${multisetDiff(a, b)}\n`); + return { + ok: 0, + total: total, + detail: `bundle literal/member multiset differs: ${multisetDiff(a, b)}`, + }; +} + +function moduleFamily(kind: string): "cjs" | "esm" { + return kind === "commonjs" || kind === "node16" || kind === "nodenext" ? "cjs" : "esm"; +} + +/** Re-emit the candidate and compare with the published JS. */ +export async function reemitCheck( + tsc: Emitter, + pub: Published, + srcDir: string, + files: string[], + ts: TsOptions, +): Promise { + const pubToSrc = new Map(); + if (files.length === 0) { + return { level: "unverified", checks: [], detail: "no source files", pubToSrc: pubToSrc }; + } + const bundles = new Set(); + for (const file of pub.jsFiles) { + const text = readText(join(pub.dir, file)); + if (text !== null && isBundle(text)) bundles.add(file); + } + const rootTarget = pub.targets.find((t) => t.subpath === ".")?.target ?? ""; + const rootText = readText(join(pub.dir, rootTarget)) ?? ""; + const rootIsCjs = + /\bexports\.|module\.exports|\brequire\(/.test(rootText) && !/^export /m.test(rootText); + const configured = optionString(ts, "module"); + const modules: string[] = []; + if (configured === "node16" || configured === "nodenext") modules.push(configured); + modules.push(rootIsCjs ? "commonjs" : "esnext"); + modules.push(rootIsCjs ? "esnext" : "commonjs"); + const configuredTarget = optionString(ts, "target"); + const targets = configuredTarget !== "" && !ts.partial ? [configuredTarget] : FALLBACK_TARGETS; + let best: Pairing = { pairs: [], equal: 0, firstDiff: "" }; + let bestEmit = new Map(); + let bestDesc = ""; + let emitError = ""; + for (const moduleKind of modules) { + for (const target of targets) { + const result = await emit(tsc, srcDir, files, ts, moduleKind, target); + if (result.error !== "") { + emitError = result.error; + continue; + } + if (bestEmit.size === 0) bestEmit = result.files; + const pairing = comparePairs(result.files, pub, bundles); + if (DEBUG) { + process.stderr.write( + `spm-debug ${pub.dir} ${moduleFamily(moduleKind)}/${target}: ${pairing.equal}/${pairing.pairs.length} ${pairing.firstDiff}\n`, + ); + } + const better = + pairing.equal > best.equal || + (pairing.equal === best.equal && pairing.pairs.length > best.pairs.length); + if (better) { + best = pairing; + bestEmit = result.files; + bestDesc = `${moduleFamily(moduleKind)}/${target}`; + } + if (pairing.pairs.length > 0 && pairing.equal === pairing.pairs.length) break; + } + if (best.pairs.length > 0 && best.equal === best.pairs.length) break; + } + const srcOf = (emittedRel: string): string => { + for (const f of files) { + if (normExt(f.replace(/\.([cm]?)tsx?$/, ".$1js")) === normExt(emittedRel)) return f; + } + return ""; + }; + for (const [pubPath, emittedPath] of best.pairs) { + const src = srcOf(emittedPath); + if (src !== "") pubToSrc.set(pubPath, src); + } + const rootTargets = pub.targets.filter((t) => t.subpath === ".").map((t) => t.target); + const rootCovered = best.pairs.some((p) => rootTargets.includes(p[0])); + if (best.pairs.length > 0 && !rootCovered && !bundles.has(rootTarget)) { + const detail = `published entry ${rootTarget} has no counterpart among ${files.length} TypeScript files (JS upstream?)`; + return { level: "unverified", checks: [], detail: detail, pubToSrc: pubToSrc }; + } + if (best.pairs.length > 0) { + const checks = [`re-emit ${bestDesc} ${best.equal}/${best.pairs.length}`]; + if (best.equal === best.pairs.length) { + return { level: "equivalent", checks: checks, detail: "", pubToSrc: pubToSrc }; + } + // A bundle entry may still verify structurally even if a stray file differs. + if (!bundles.has(rootTarget)) { + return { level: "mismatch", checks: checks, detail: best.firstDiff, pubToSrc: pubToSrc }; + } + } + if (bundles.has(rootTarget) && bestEmit.size > 0) { + const s = structuralCheck(rootText, bestEmit); + const checks = [`structural bundle vs ${s.total} modules`]; + if (s.total > 0 && s.ok === s.total) { + return { level: "structural", checks: checks, detail: "", pubToSrc: pubToSrc }; + } + return { level: "unverified", checks: checks, detail: s.detail, pubToSrc: pubToSrc }; + } + const detail = + emitError !== "" + ? `tsc emitted nothing: ${emitError}` + : "no published module matched an emitted module"; + return { level: "unverified", checks: [], detail: detail, pubToSrc: pubToSrc }; +} + +// ── surface ────────────────────────────────────────────────────────────── + +function resolveRelative( + root: string, + fromRel: string, + spec: string, + exts: string[], +): string | null { + const base = normalize(join(dirname(fromRel), spec)); + const stem = base.replace(/\.([cm]?)js$/, ""); + const candidates = [base]; + for (const ext of exts) candidates.push(`${stem}${ext}`, `${base}${ext}`, `${base}/index${ext}`); + for (const c of candidates) { + if (!c.startsWith("..") && isFile(join(root, c)) && !c.endsWith(".js")) return c; + } + return null; +} + +/** Exported names of `entry`, following relative `export *` chains. */ +export function exportedNames(root: string, entry: string, exts: string[]): Set { + const names = new Set(); + const seen = new Set(); + const queue = [entry]; + while (queue.length > 0) { + const rel = queue.shift() as string; + if (seen.has(rel)) continue; + seen.add(rel); + const text = readText(join(root, rel)); + if (text === null) continue; + const scan = exportsOf(tokenize(text)); + for (const name of scan.names) names.add(name); + for (const star of scan.stars) { + if (!star.startsWith(".")) { + names.add(`*:${star}`); + continue; + } + const target = resolveRelative(root, rel, star, exts); + if (target !== null) queue.push(target); + } + } + names.delete("default"); + return names; +} + +export interface SurfaceResult { + level: SurfaceLevel; + detail: string; +} + +export function surfaceCheck(pub: Published, srcDir: string, srcEntry: string): SurfaceResult { + const dts = typesEntry(pub); + if (dts === null) return { level: "unchecked", detail: "no published declarations" }; + const published = exportedNames(pub.dir, dts, [".d.ts", ".d.mts", ".d.cts"]); + const source = exportedNames(srcDir, srcEntry, [".ts", ".tsx", ".mts", ".cts", ".d.ts"]); + const missing = [...published].filter((n) => !source.has(n)).sort(); + const extra = [...source].filter((n) => !published.has(n)).sort(); + if (missing.length === 0 && extra.length === 0) return { level: "identical", detail: "" }; + const parts: string[] = []; + if (missing.length > 0) parts.push(`missing in source: ${missing.slice(0, 5).join(", ")}`); + if (extra.length > 0) parts.push(`extra in source: ${extra.slice(0, 5).join(", ")}`); + return { level: "mismatch", detail: parts.join("; ") }; +} + +/** Write `files` (package-relative path → content) under a fresh dir. */ +export function materializeFiles(dir: string, files: Map): void { + rmSync(dir, { recursive: true, force: true }); + for (const [rel, content] of files) { + const dest = join(dir, rel); + mkdirSync(dest.slice(0, dest.lastIndexOf("/")), { recursive: true }); + writeFileSync(dest, content); + } +} diff --git a/packages/pm/tsconfig.json b/packages/pm/tsconfig.json new file mode 100644 index 0000000000..273ab55e25 --- /dev/null +++ b/packages/pm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"], + "declaration": false, + "sourceMap": false, + "erasableSyntaxOnly": true, + "tsBuildInfoFile": "../../node_modules/.cache/scriptc-tsc/pm.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d9e02acc..8242d09aa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,8 @@ importers: packages/llvm-win32-x64-msvc: {} + packages/pm: {} + packages/runtime: {} packages/runtime-android-arm64: {} From f2eeda7fb8b4b8aa317717521a3be09bee3d8d47 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:40:55 -0700 Subject: [PATCH 2/6] Compile augmented npm packages from scriptc-pm source overlays (--sources) `scriptc build|run|coverage --sources[=]` reads a scriptc-pm schema-1 manifest (default: the nearest .scriptc-pm/manifest.json above the entry) and compiles each augmented package instance from its TypeScript overlay instead of its published JS. - Instances are keyed by realpath(installedDir). The resolver redirects an import that lands in an instance's installed directory to the instance's exports inside its overlay (resolveSourceOverlayModule); overlay files compile as program modules. - tsgo gets the pilot's "paths" injection, but only for names with a single augmented instance; other instances start dropped, and a second installed copy of a redirected name drops it too. - Fallback per instance through the drop-and-reload loop: a preflight diagnostic in an overlay, a program type error naming it, a top-level require() edge to it, a lowering refusal inside it, or a program that only fails against the source surface (probe) drops the overlay and the package compiles from published JS. Reasons are reported on stderr and as "source"/"source-fallback" rows in coverage. - Build caches key by the manifest's lockDigest and bytes instead of being bypassed; the network-fetching --provenance-sources pilot stays uncacheable and becomes one more producer of the same overlay set. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + packages/cli/src/bootstrap.ts | 2 + packages/compiler/src/cli/command.ts | 51 +- packages/compiler/src/cli/usage.ts | 24 + packages/compiler/src/coverage/report.ts | 69 +- .../compiler/src/coverage/verdict-cache.ts | 8 +- .../src/executable/executable-cache.ts | 5 + packages/compiler/src/frontend/analysis.ts | 9 +- .../src/frontend/lowering/lower-stmts.ts | 4 +- .../compiler/src/frontend/lowering/lowerer.ts | 4 +- packages/compiler/src/frontend/pipeline.ts | 326 ++++- packages/compiler/src/frontend/program.ts | 21 +- .../compiler/src/frontend/provenance-core.ts | 1 + .../src/frontend/provenance-registry.ts | 98 +- packages/compiler/src/frontend/resolve.ts | 118 +- .../compiler/src/frontend/source-overlays.ts | 380 ++++++ packages/compiler/src/index.ts | 19 +- .../compiler/src/library/library-cache.ts | 3 + packages/compiler/src/native/compiler.test.ts | 2 + packages/compiler/src/native/prepare.ts | 10 +- .../fixtures/source-overlays/project/emit.ts | 20 + .../fixtures/source-overlays/project/flags.ts | 17 + .../node_modules/@openfeature/core/LICENSE | 201 +++ .../@openfeature/core/dist/esm/index.js | 1166 +++++++++++++++++ .../@openfeature/core/dist/esm/package.json | 3 + .../@openfeature/core/dist/types.d.ts | 1076 +++++++++++++++ .../@openfeature/core/package.json | 49 + .../node_modules/@openfeature/core | 1 + .../@openfeature/server-sdk/LICENSE | 201 +++ .../@openfeature/server-sdk/dist/esm/index.js | 634 +++++++++ .../server-sdk/dist/esm/package.json | 3 + .../@openfeature/server-sdk/dist/types.d.ts | 420 ++++++ .../@openfeature/server-sdk/package.json | 56 + .../mitt@3.0.1/node_modules/mitt/LICENSE | 21 + .../mitt@3.0.1/node_modules/mitt/dist/mitt.js | 2 + .../node_modules/mitt/dist/mitt.mjs | 2 + .../node_modules/mitt/dist/mitt.umd.js | 2 + .../mitt@3.0.1/node_modules/mitt/index.d.ts | 21 + .../mitt@3.0.1/node_modules/mitt/package.json | 85 ++ .../node_modules/path-to-regexp/LICENSE | 21 + .../path-to-regexp/dist.es2015/index.js | 415 ++++++ .../path-to-regexp/dist/index.d.ts | 127 ++ .../node_modules/path-to-regexp/dist/index.js | 425 ++++++ .../node_modules/path-to-regexp/package.json | 63 + .../project/node_modules/@openfeature/core | 1 + .../node_modules/@openfeature/server-sdk | 1 + .../source-overlays/project/node_modules/mitt | 1 + .../project/node_modules/path-to-regexp | 1 + .../source-overlays/project/package.json | 1 + .../source-overlays/project/require.cts | 3 + .../source-overlays/project/routes.ts | 18 + .../source-overlays/sources/core/LICENSE | 201 +++ .../sources/core/src/client/client.ts | 11 + .../sources/core/src/client/index.ts | 1 + .../core/src/errors/flag-not-found-error.ts | 13 + .../sources/core/src/errors/general-error.ts | 13 + .../sources/core/src/errors/index.ts | 45 + .../core/src/errors/invalid-context-error.ts | 13 + .../src/errors/open-feature-error-abstract.ts | 20 + .../sources/core/src/errors/parse-error.ts | 13 + .../core/src/errors/provider-fatal-error.ts | 13 + .../src/errors/provider-not-ready-error.ts | 13 + .../src/errors/targeting-key-missing-error.ts | 13 + .../core/src/errors/type-mismatch-error.ts | 13 + .../sources/core/src/evaluation/context.ts | 36 + .../sources/core/src/evaluation/evaluation.ts | 122 ++ .../sources/core/src/evaluation/index.ts | 2 + .../sources/core/src/events/event-utils.ts | 27 + .../sources/core/src/events/eventing.ts | 113 ++ .../sources/core/src/events/events.ts | 73 ++ .../core/src/events/generic-event-emitter.ts | 110 ++ .../sources/core/src/events/index.ts | 5 + .../core/src/events/provider-event-emitter.ts | 23 + .../sources/core/src/filter.ts | 9 + .../core/src/hooks/evaluation-lifecycle.ts | 28 + .../sources/core/src/hooks/hook-data.ts | 72 + .../sources/core/src/hooks/hook.ts | 49 + .../sources/core/src/hooks/hooks.ts | 25 + .../sources/core/src/hooks/index.ts | 4 + .../source-overlays/sources/core/src/index.ts | 12 + .../sources/core/src/logger/default-logger.ts | 17 + .../sources/core/src/logger/index.ts | 3 + .../sources/core/src/logger/logger.ts | 22 + .../sources/core/src/logger/safe-logger.ts | 48 + .../sources/core/src/open-feature.ts | 443 +++++++ .../sources/core/src/provider/index.ts | 5 + .../src/provider/multi-provider/errors.ts | 57 + .../provider/multi-provider/status-tracker.ts | 113 ++ .../strategies/base-evaluation-strategy.ts | 152 +++ .../strategies/comparison-strategy.ts | 66 + .../strategies/first-match-strategy.ts | 36 + .../strategies/first-successful-strategy.ts | 32 + .../multi-provider/strategies/index.ts | 4 + .../core/src/provider/multi-provider/types.ts | 6 + .../sources/core/src/provider/provider.ts | 137 ++ .../sources/core/src/telemetry/attributes.ts | 88 ++ .../core/src/telemetry/evaluation-event.ts | 87 ++ .../core/src/telemetry/flag-metadata.ts | 20 + .../sources/core/src/telemetry/index.ts | 3 + .../sources/core/src/tracking/index.ts | 1 + .../core/src/tracking/tracking-event.ts | 13 + .../sources/core/src/type-guards.ts | 35 + .../sources/core/src/types/index.ts | 3 + .../sources/core/src/types/metadata.ts | 1 + .../sources/core/src/types/paradigm.ts | 4 + .../sources/core/src/types/structure.ts | 7 + .../source-overlays/sources/mitt/LICENSE | 21 + .../source-overlays/sources/mitt/src/index.ts | 123 ++ .../sources/path-to-regexp/LICENSE | 21 + .../sources/path-to-regexp/src/index.ts | 641 +++++++++ .../sources/server-sdk/LICENSE | 201 +++ .../sources/server-sdk/src/client/client.ts | 25 + .../sources/server-sdk/src/client/index.ts | 1 + .../client/internal/open-feature-client.ts | 436 ++++++ .../server-sdk/src/evaluation/evaluation.ts | 177 +++ .../server-sdk/src/evaluation/index.ts | 1 + .../sources/server-sdk/src/events/events.ts | 3 + .../sources/server-sdk/src/events/index.ts | 2 + .../events/internal/internal-event-emitter.ts | 9 + .../src/events/open-feature-event-emitter.ts | 21 + .../sources/server-sdk/src/hooks/hook.ts | 7 + .../sources/server-sdk/src/hooks/index.ts | 1 + .../sources/server-sdk/src/index.ts | 9 + .../sources/server-sdk/src/open-feature.ts | 277 ++++ .../in-memory-provider/flag-configuration.ts | 36 + .../in-memory-provider/in-memory-provider.ts | 139 ++ .../src/provider/in-memory-provider/index.ts | 1 + .../variant-not-found-error.ts | 15 + .../sources/server-sdk/src/provider/index.ts | 3 + .../server-sdk/src/provider/no-op-provider.ts | 38 + .../server-sdk/src/provider/provider.ts | 68 + .../sources/server-sdk/src/tracking/index.ts | 1 + .../server-sdk/src/tracking/tracking.ts | 12 + ...-storage-transaction-context-propagator.ts | 19 + .../src/transaction-context/index.ts | 3 + .../no-op-transaction-context-propagator.ts | 18 + .../transaction-context.ts | 63 + tests/harness/source-overlays.test.ts | 306 +++++ 138 files changed, 11271 insertions(+), 136 deletions(-) create mode 100644 packages/compiler/src/frontend/source-overlays.ts create mode 100644 tests/fixtures/source-overlays/project/emit.ts create mode 100644 tests/fixtures/source-overlays/project/flags.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json create mode 120000 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js create mode 100644 tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json create mode 120000 tests/fixtures/source-overlays/project/node_modules/@openfeature/core create mode 120000 tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk create mode 120000 tests/fixtures/source-overlays/project/node_modules/mitt create mode 120000 tests/fixtures/source-overlays/project/node_modules/path-to-regexp create mode 100644 tests/fixtures/source-overlays/project/package.json create mode 100644 tests/fixtures/source-overlays/project/require.cts create mode 100644 tests/fixtures/source-overlays/project/routes.ts create mode 100644 tests/fixtures/source-overlays/sources/core/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/core/src/client/client.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/client/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/eventing.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/events.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/filter.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/hooks/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/open-feature.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/provider/provider.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/tracking/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/type-guards.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/index.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/metadata.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts create mode 100644 tests/fixtures/source-overlays/sources/core/src/types/structure.ts create mode 100644 tests/fixtures/source-overlays/sources/mitt/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/mitt/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/LICENSE create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts create mode 100644 tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts create mode 100644 tests/harness/source-overlays.test.ts diff --git a/.gitignore b/.gitignore index e5b54a5fac..7d754d516f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules/ !tests/fixtures/npm/cases/*/node_modules/ !tests/library-mode/*/node_modules/ !tests/fixtures/provenance/node_modules/ +!tests/fixtures/source-overlays/project/node_modules/ +!tests/fixtures/source-overlays/project/node_modules/.pnpm/*/node_modules/ !tests/fixtures/fetch/node_modules/ !tests/fixtures/gateway-e2e/node_modules/ !tests/fixtures/node-types/node_modules/ @@ -32,6 +34,7 @@ output/ !tests/fixtures/npm/cases/workspace-copied/members/*/dist/ !tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/ !tests/fixtures/provenance/node_modules/*/dist/ +!tests/fixtures/source-overlays/project/node_modules/.pnpm/**/dist/ .scriptc/ *.log .DS_Store diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 2d12883fa6..0bc976aa5a 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -66,6 +66,7 @@ async function tryFastPath(): Promise { values["emit-ir"] || values.lib || values["provenance-sources"] || + process.argv.some((arg) => arg === "--sources" || arg.startsWith("--sources=")) || (values["external-types"] ?? []).length > 0 ) return null; @@ -191,6 +192,7 @@ async function coverageFastPath( values.optimization !== undefined || values["windows-subsystem"] !== undefined || values["provenance-sources"] || + process.argv.some((arg) => arg === "--sources" || arg.startsWith("--sources=")) || values["emit-ir"] || (values["npm-static"] ?? []).length > 0 || (values["external-types"] ?? []).length > 0 diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index d729de866d..1527f9a79a 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -13,10 +13,16 @@ import { buildEnvelope } from "../diagnostics/envelope.js"; import { renderWarnings } from "../diagnostics/render.js"; import { isNativeOptimization } from "../backend/optimization.js"; import { setProvenanceSources } from "../frontend/provenance-registry.js"; +import { + findSourceManifest, + installSourceOverlays, + loadSourceManifest, + sourceOverlayFallbacks, +} from "../frontend/source-overlays.js"; import type { CliHost, NativeCacheWarmProfile } from "./host.js"; import { resolveOutputOptions } from "./output-options.js"; import { selectOutputPaths } from "./paths.js"; -import { CLI_OPTIONS, USAGE } from "./usage.js"; +import { CLI_OPTIONS, normalizeCliArgs, USAGE } from "./usage.js"; /* The exit discipline: NEVER process.exit() after writing output. stdout/ * stderr to a PIPE are async streams — process.exit() drops whatever libuv @@ -46,7 +52,12 @@ function parseCli( typeof parseArgs<{ options: typeof CLI_OPTIONS; allowPositionals: true; allowNegative: true }> > { try { - return parseArgs({ args, options: CLI_OPTIONS, allowPositionals: true, allowNegative: true }); + return parseArgs({ + args: normalizeCliArgs(args), + options: CLI_OPTIONS, + allowPositionals: true, + allowNegative: true, + }); } catch (err) { // parseArgs appends a paragraph about `--` and positionals to the // unknown-option message; the first sentence is the part that names @@ -57,6 +68,34 @@ function parseCli( } } +/** --sources: reads the manifest (the given path, or the nearest + * `.scriptc-pm/manifest.json` above `anchor`) and installs its overlays + * for the compile. Without the flag, any previous set is cleared. */ +function installSourcesFlag(value: string | undefined, anchor: string): void { + installSourceOverlays(null); + if (value === undefined) return; + const manifestPath = value !== "" ? resolve(value) : findSourceManifest(anchor); + if (manifestPath === null) { + fail( + `scriptc: --sources found no .scriptc-pm/manifest.json above ${dirname(anchor)} (run scriptc-pm sync, or pass --sources=)`, + ); + } + try { + installSourceOverlays(loadSourceManifest(manifestPath)); + } catch (e) { + fail(`scriptc: ${e instanceof Error ? e.message : String(e)}`); + } +} + +/** One stderr line per overlay instance the build fell back on. */ +function reportSourceFallbacks(): void { + for (const f of sourceOverlayFallbacks()) { + process.stderr.write( + `scriptc: sources: ${f.name}@${f.version} compiles from published JS (source overlay dropped: ${f.reason})\n`, + ); + } +} + async function main(args: string[], host: CliHost): Promise { const { values, positionals } = parseCli(args); const externalTypeArgs = values["external-types"] ?? []; @@ -72,6 +111,9 @@ async function main(args: string[], host: CliHost): Promise { } const [command, inputArg] = positionals; + if (values.sources !== undefined && values["provenance-sources"]) { + fail(`--sources and --provenance-sources are alternatives\n\n${USAGE}`); + } if (command === "cache") { if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`); @@ -88,6 +130,7 @@ async function main(args: string[], host: CliHost): Promise { values["detached-exit"] || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || + values.sources !== undefined || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || @@ -159,6 +202,7 @@ async function main(args: string[], host: CliHost): Promise { ); } const profilePath = resolve(profileArg); + installSourcesFlag(values.sources, profilePath); const libOutDir = values.out ? dirname(resolve(values.out)) : join(dirname(profilePath), ".scriptc"); @@ -169,6 +213,7 @@ async function main(args: string[], host: CliHost): Promise { emitIr: values["emit-ir"], sanitize: values.sanitize, }); + reportSourceFallbacks(); if (!result.ok) { const color = process.stderr.isTTY ?? false; process.stderr.write( @@ -322,6 +367,7 @@ async function main(args: string[], host: CliHost): Promise { // source "paths" at creation): attestations and source trees fetch (or // ride the content-addressed cache / the offline manifest), the registry // installs, and every fallback prints as a note — never a failure. + installSourcesFlag(values.sources, input); const provenance = values["provenance-sources"] ? await host.resolveProvenanceSources(input) : null; @@ -387,6 +433,7 @@ async function main(args: string[], host: CliHost): Promise { ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}), }); + if (command === "build") reportSourceFallbacks(); if (!result.ok) { if (printDiagnostics) { const envelope = buildEnvelope({ diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index a0388374b8..ccd7d78dcc 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -59,6 +59,13 @@ Options: direct import: own .d.ts, unminified JS, no build-transform markers). Packages that fail preflight are refused with a diagnostic. Eligible dependencies compile automatically. + --sources[=] + EXPERIMENTAL: compile augmented npm package instances + from the TypeScript source overlays a scriptc-pm + manifest describes (default: the nearest + .scriptc-pm/manifest.json above the entry). An overlay + that does not compile falls back to the package's + published JS --provenance-sources EXPERIMENTAL: compile npm dependencies from their provenance-attested SOURCE (fetched at the attested @@ -73,6 +80,22 @@ Options: -v, --version print the version `; +/** `--sources` takes an optional value (`--sources` or `--sources=`), + * which parseArgs cannot express: the bare flag becomes an empty value, + * meaning "find the manifest". A separate `--sources ` argument is + * not accepted, so a following positional is never swallowed. */ +export function normalizeCliArgs(args: readonly string[]): string[] { + const out: string[] = []; + for (const arg of args) { + if (arg === "--") { + out.push(...args.slice(out.length)); + break; + } + out.push(arg === "--sources" ? "--sources=" : arg); + } + return out; +} + export const CLI_OPTIONS = { out: { type: "string", short: "o" }, emit: { type: "string" }, @@ -89,6 +112,7 @@ export const CLI_OPTIONS = { ffi: { type: "string" }, "npm-static": { type: "string", multiple: true }, "provenance-sources": { type: "boolean", default: false }, + sources: { type: "string" }, "external-types": { type: "string", multiple: true }, lib: { type: "boolean", default: false }, profile: { type: "string" }, diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 9d0776d9a7..b593ac9b79 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -20,7 +20,7 @@ import { } from "../diagnostics/envelope.js"; import { renderDiagnostics } from "../diagnostics/render.js"; import type { LowerStats } from "../frontend/lowering/lowerer.js"; -import type { ProvenanceSources } from "../frontend/provenance-registry.js"; +import type { SourceOverlayReport } from "../frontend/source-overlays.js"; export interface CoverageInput { file: string; @@ -41,11 +41,12 @@ export interface CoverageInput { /** Each dependency's native compilation outcome, with the first refusal * reason for a refused package. */ npmStatic?: NpmStaticStatus[]; - /** --provenance-sources: the resolved source-mapped packages and the - * fallback notes — the report renders per-package attribution from - * statsByFile (statements inside each package's source dir). */ - provenance?: ProvenanceSources; - /** --provenance-sources: per-file statement attribution from the emit + /** --sources / --provenance-sources: the packages compiled from source + * overlays, the ones that fell back, and the producer's notes — the + * report renders per-package attribution from statsByFile (statements + * inside each package's overlay dir). */ + sourceOverlays?: SourceOverlayReport; + /** --sources / --provenance-sources: per-file statement attribution from the emit * pass (LowerResult.statsByFile). */ statsByFile?: Map; /** --provenance-sources: diagnostics of elided pure-annotated dead @@ -55,11 +56,15 @@ export interface CoverageInput { preflightFailed: boolean; } -/** One dependency's native compilation outcome. */ +/** One dependency's native compilation outcome. "source" compiles the + * package instance (`name@version`) from its --sources overlay; + * "source-fallback" dropped the overlay and the package compiles from its + * published JS (its own row says how that went). */ export interface NpmStaticStatus { package: string; - status: "static" | "refused"; - /** The first refusal reason for a refused package. */ + status: "static" | "refused" | "source" | "source-fallback"; + /** The first refusal reason for a refused package; the drop reason for a + * source fallback. */ detail?: string; } @@ -194,29 +199,40 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(` ${c(DIM, "npm package compilation:")}`); const widestP = Math.max(...npmStatic.map((s) => s.package.length)); for (const s of npmStatic) { + const detail = s.detail !== undefined ? ` ${c(DIM, `(${s.detail})`)}` : ""; const status = s.status === "static" ? c(GREEN, "static") - : c(RED, "refused") + (s.detail !== undefined ? ` ${c(DIM, `(${s.detail})`)}` : ""); + : s.status === "source" + ? c(GREEN, "static from source") + : s.status === "source-fallback" + ? c(YELLOW, "published JS") + detail + : c(RED, "refused") + detail; out.push(` ${s.package.padEnd(widestP)} ${status}`); } out.push(""); } - // --provenance-sources: per-package attribution — the answer to "did - // the DEPENDENCY's statements compile static?", computed by summing - // statsByFile entries under each package's source dir. Elided - // pure-annotated dead consts and fallback notes render here too, so - // the provenance story is complete in one place. - if (input.provenance !== undefined) { - const prov = input.provenance; - out.push( - ` ${c(BOLD, "provenance sources")} ${c(DIM, "(attested source compiled as program modules)")}`, - ); - for (const pkg of prov.packages) { + // --sources / --provenance-sources: per-package attribution — the + // answer to "did the DEPENDENCY's statements compile static?", computed + // by summing statsByFile entries under each overlay dir. Elided + // pure-annotated dead consts, fallbacks, and producer notes render here + // too, so the source story is complete in one place. + if (input.sourceOverlays !== undefined) { + const overlays = input.sourceOverlays; + out.push(` ${c(BOLD, "source overlays")} ${c(DIM, `(${overlays.origin})`)}`); + for (const pkg of overlays.packages) { + const origin = pkg.origin !== null ? ` ${c(DIM, pkg.origin)}` : ""; + out.push(` ${pkg.name}@${pkg.version}${origin}`); + if (pkg.fallback !== null) { + out.push( + ` ${c(YELLOW, "published JS")} ${c(DIM, `(source overlay dropped: ${pkg.fallback})`)}`, + ); + continue; + } let pTotal = 0; let pFailed = 0; - const dir = pkg.dir.endsWith("/") ? pkg.dir : `${pkg.dir}/`; + const dir = `${pkg.dir}/`; for (const [file, s] of input.statsByFile ?? new Map()) { if (!file.startsWith(dir)) continue; @@ -229,13 +245,6 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions const pOk = pTotal - pFailed; const pPct = pTotal === 0 ? 100 : Math.floor((pOk / pTotal) * 100); const pColor = pPct === 100 ? GREEN : pPct >= 75 ? YELLOW : RED; - const shortRepo = pkg.repo - .replace(/^git\+/, "") - .replace(/^https:\/\//, "") - .replace(/@refs\/.*$/, ""); - out.push( - ` ${pkg.name}@${pkg.version} ${c(DIM, `${shortRepo} @ ${pkg.commit.slice(0, 12)}`)}`, - ); out.push( ` ${pTotal} statements: ${pOk} static ${c(pColor, c(BOLD, `(${pPct}%)`))}, ${pFailed} fenced` + (elided > 0 @@ -243,7 +252,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions : ""), ); } - for (const note of prov.notes) out.push(` ${c(YELLOW, "note")} ${note}`); + for (const note of overlays.notes) out.push(` ${c(YELLOW, "note")} ${note}`); out.push(""); } diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index 841e2b57b1..8619c5d793 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -19,6 +19,7 @@ import { frontendInputsStillMatch, type FrontendInputSnapshot, } from "../frontend/input-tracker.js"; +import { sourceOverlayCacheIdentity } from "../frontend/source-overlays.js"; const SCHEMA = "scriptc.coverage-verdict.v2"; @@ -97,6 +98,10 @@ export function verdictKey( return null; } } + // --sources: the overlay set's identity joins the key; a set that cannot + // be cached (the network-fetching pilot) never replays. + const overlays = sourceOverlayCacheIdentity(); + if (overlays === null) return null; const npmStatic = opts.npmStatic === undefined ? "" @@ -120,13 +125,14 @@ export function verdictKey( opts.ffiProfilePath ?? "", ffi, external.join("\n"), + ...(overlays !== undefined ? [`sources:${overlays}`] : []), ].join("\0"), ); } function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null { if ( - coverage.provenance !== undefined || + coverage.sourceOverlays !== undefined || coverage.statsByFile !== undefined || coverage.provenanceElided !== undefined ) diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd0..02da6ba8fd 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -121,6 +121,9 @@ export interface EarlyExecutableCacheOptions { nodeVersion: string; implementation: string; implementationDependencies: CompilerImplementationDependency[]; + /** --sources: the overlay set's identity (manifest lock digest and + * bytes). Omitted without --sources, preserving existing keys. */ + sources?: string; } export type EarlyExecutableRouteOptions = Omit< @@ -232,6 +235,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { options.nodeVersion, options.implementation, ...ffiParts, + ...(options.sources !== undefined ? [`sources:${options.sources}`] : []), ]); } @@ -280,6 +284,7 @@ function routeKey(options: Omit 0 ? { npmStatic: fe.npmStatic } : {}), - // --provenance-sources: the per-package attribution inputs (the - // report aggregates statsByFile under each package's source dir). - ...(provenance !== null ? { provenance } : {}), + // --sources: the per-package attribution inputs (the report + // aggregates statsByFile under each overlay dir). + ...(sourceOverlays !== null ? { sourceOverlays } : {}), ...(lowered.statsByFile ? { statsByFile: lowered.statsByFile } : {}), ...(lowered.provenanceElided ? { provenanceElided: lowered.provenanceElided } : {}), preflightFailed: false, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 367c147f85..bd381f434e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -78,7 +78,7 @@ import { stdlibGlobalNameOf, } from "./surfaces.js"; import { isNativeBuiltinValueInitializer } from "./lower-builtin-values.js"; -import { isProvenanceSourceFile } from "../provenance-registry.js"; +import { isSourceOverlayFile } from "../source-overlays.js"; import { ambientUndefVarRootOf, lowerImportEquals, @@ -603,7 +603,7 @@ function pureAnnotatedDeadConst(stmt: ts.Statement, sf: ts.SourceFile): boolean * shape. */ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean { const sf = decl.getSourceFile(); - if (!isProvenanceSourceFile(sf.fileName)) return false; + if (!isSourceOverlayFile(sf.fileName)) return false; const stmt = decl.parent?.parent; if (!ts.isVariableStatement(stmt) || !pureAnnotatedDeadConst(stmt, sf)) return false; const diagsBefore = lowerer.diags.length; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index d4cf827527..074a77a6c5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -189,7 +189,7 @@ import { UNDEFINED_T, VOID, } from "../../ir/ir.js"; -import { provenanceActive } from "../provenance-registry.js"; +import { sourceOverlaysActive } from "../source-overlays.js"; import { ambientDtsPath, canonicalBuiltinModule, @@ -2714,7 +2714,7 @@ export class Lowerer { * active and this is the emit/discovery lane — mirror the CALLER's * suppressStats guard, this method only gates remainder). */ bumpFileStat(file: string, kind: "total" | "failed"): void { - if (this.remainder || !provenanceActive()) return; + if (this.remainder || !sourceOverlaysActive()) return; let s = this.statsByFile.get(file); if (!s) this.statsByFile.set(file, (s = { total: 0, failed: 0 })); if (kind === "total") s.total += 1; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 2ec9553f67..f5aa7f9c05 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -19,8 +19,28 @@ import { npmStaticOffenders, npmStaticPackageOfPath, } from "./npm-static.js"; -import { clearResolveCaches, resolveBareModule, resolveWorkspaceSourceModule } from "./resolve.js"; -import { provenanceEntryFor } from "./provenance-registry.js"; +import { + clearResolveCaches, + resolveBareModule, + resolveSourceOverlayModule, + resolveWorkspaceSourceModule, +} from "./resolve.js"; +import { + activeSourceOverlays, + beginSourceOverlayCompile, + dropSourceOverlay, + isSourceOverlayFile, + restoreSourceOverlayDrops, + snapshotSourceOverlayDrops, + sourceOverlayDropReason, + sourceOverlayOfFile, + sourceOverlayReport, + sourceOverlaySet, + sourceOverlaysActive, + takeSourceOverlayProblems, + type SourceOverlayReport, +} from "./source-overlays.js"; +import { npmStaticRefusalDiag } from "../diagnostics/diagnostic.js"; import { isJsSourceFileName } from "./tsc-codes.js"; import { isRelativeSpecifier, packageNameOfSpecifier } from "./workspace-registry.js"; import { lowerToIr, type LowerOptions, type LowerResult } from "./lowering/lowerer.js"; @@ -72,6 +92,9 @@ export interface Frontend { npmStatic: NpmStaticStatus[]; /** Each judged npm package's first import site, used to anchor refusals. */ npmImportSites: ReadonlyMap; + /** --sources: the overlay instances the final program compiles from + * source, and every instance that fell back to published JS. */ + sourceOverlays: () => SourceOverlayReport | null; /** Releases the frontend's resources (the spawned tsgo server). Call * exactly once, after the last lower(). */ dispose: () => void; @@ -122,9 +145,9 @@ function detectAutoPackages( // experimental-import.ts), typed by their declarations. if (spec === CONTRACT_TYPES_MODULE || spec === THREADS_MODULE || spec === EXPERIMENTAL_MODULE) continue; - // Attested entries already resolve to program sources, so the - // published package's declaration eligibility does not apply. - if (provenanceEntryFor(spec) !== null) continue; + // Augmented instances resolve to program sources, so the published + // package's declaration eligibility does not apply. + if (resolveSourceOverlayModule(sf.fileName, spec) !== null) continue; if (resolveWorkspaceSourceModule(sf.fileName, spec) !== null) continue; if (isRelativeSpecifier(spec) || spec.startsWith("node:") || spec.startsWith("#")) continue; // Bare builtin names ("fs", "path") are the builtin machinery's @@ -198,6 +221,209 @@ function packagesNamedByDiag(message: string, optedIn: ReadonlySet): Set return hits; } +/** Where a diagnostic sits, relative to its overlay ("src/x.ts:12"). */ +function overlaySite( + file: string, + overlayDir: string, + text: string | undefined, + start: number, +): string { + const rel = file.slice(overlayDir.length + 1); + if (text === undefined) return rel; + let line = 1; + for (let i = 0; i < start && i < text.length; i++) if (text.charCodeAt(i) === 10) line++; + return `${rel}:${line}`; +} + +/** --sources: the active overlay instances a load implicates, each with the + * first reason. An instance falls back to published JS when its source + * fails preflight (any diagnostic anchored in an overlay file — module-form + * refusals and type errors alike), when a program type error names its + * specifier or files, when a top-level require() edge targets it (the + * CommonJS shape of a published package is not synthesized for source), + * or when the resolver noted a checker disagreement. */ +function sourceOverlayCulprits( + load: LoadResult, + preflight: readonly ScrDiagnostic[], +): Map { + const culprits = takeSourceOverlayProblems(); + if (!sourceOverlaysActive()) return culprits; + const add = (key: string, reason: string): void => { + if (!culprits.has(key)) culprits.set(key, reason); + }; + const texts = new Map(); + for (const sf of [load.entry, ...load.moduleOrder]) texts.set(sf.fileName, sf.text); + const active = activeSourceOverlays(); + for (const d of preflight) { + const owner = sourceOverlayOfFile(d.loc.file); + if (owner !== null && isSourceOverlayFile(d.loc.file)) { + const site = overlaySite(d.loc.file, owner.overlayDir, texts.get(d.loc.file), d.loc.start); + add(owner.key, `${d.code} at ${site}: ${d.message}`); + continue; + } + if (d.code !== "SC0001") continue; + for (const instance of active) { + const named = + d.message.includes(`'"${instance.name}"'`) || + d.message.includes(`'"${instance.name}/`) || + d.message.includes(`${instance.overlayDir}/`); + if (named) + add(instance.key, `the program does not typecheck against its source: ${d.message}`); + } + } + for (const sf of [load.entry, ...load.moduleOrder]) { + for (const stmt of sf.statements) { + for (const req of requiresOf(stmt)) { + const target = resolveSourceOverlayModule(sf.fileName, req.spec, "require"); + const owner = target === null ? null : sourceOverlayOfFile(target); + if (owner === null) continue; + add( + owner.key, + `require() of '${req.spec}' from ${sf.fileName} (CommonJS interop for source overlays is not implemented)`, + ); + } + } + } + for (const [key, reason] of takeSourceOverlayProblems()) add(key, reason); + return culprits; +} + +function dropSourceOverlays(culprits: ReadonlyMap): void { + for (const [key, reason] of culprits) dropSourceOverlay(key, reason); +} + +/** The first diagnostic outside every overlay, as a one-line reason. */ +function surfaceReason(verb: string, diagnostics: readonly ScrDiagnostic[]): string { + const d = diagnostics.find((x) => sourceOverlayOfFile(x.loc.file) === null) ?? diagnostics[0]!; + return `the program does not ${verb} against its source surface (${d.code} in ${d.loc.file}: ${d.message.split("\n")[0]})`; +} + +/** The keys of the overlays a frontend compiles from source. */ +function frontendOverlayKeys(fe: Frontend): string[] { + return (fe.sourceOverlays()?.packages ?? []).filter((p) => p.fallback === null).map((p) => p.key); +} + +/** The keys of active instances with files in the program. */ +function usedSourceOverlays(load: LoadResult): Set { + const used = new Set(); + if (!sourceOverlaysActive()) return used; + for (const sf of load.program.getImplementationSourceFiles()) { + const owner = sourceOverlayOfFile(sf.fileName); + if (owner !== null && isSourceOverlayFile(sf.fileName)) used.add(owner.key); + } + return used; +} + +/** True when a reloaded frontend can be lowered at all: a clean preflight + * and no refused package (executable and library builds stop on both). */ +function frontendLowerable(fe: Frontend, coverage: boolean): boolean { + return ( + fe.preflight.length === 0 && + (coverage || !fe.npmStatic.some((entry) => entry.status === "refused")) + ); +} + +/** --sources: lowering can still refuse a statement after preflight + * passed. A refusal inside an overlay drops that instance; a failure + * outside every overlay probes the published surface with every used + * overlay dropped (the program may only fail against a declared source + * type the published JS's inferred surface avoids) and keeps the probe + * only when it lowers cleanly. Each fallback reloads the frontend, so the + * dropped packages resolve to their published JS. The arrays and maps + * callers read are refreshed in place, so copies of the wrapper observe + * the reload too. */ +function withSourceOverlayLoweringFallback( + first: Frontend, + reload: () => Frontend, + entryPath: string, +): Frontend { + let current = first; + const adopt = (next: Frontend): void => { + current.dispose(); + current = next; + fe.preflight.splice(0, fe.preflight.length, ...next.preflight); + fe.npmStatic.splice(0, fe.npmStatic.length, ...next.npmStatic); + const sites = fe.npmImportSites as Map; + sites.clear(); + for (const [k, v] of next.npmImportSites) sites.set(k, v); + }; + /** The failure a reload that cannot be lowered reports, shaped like the + * checks executable and library builds run before lowering. */ + const unlowerable = (stats: LowerResult["stats"], coverage: boolean): LowerResult => ({ + module: null, + diagnostics: [ + ...current.preflight, + ...(coverage ? [] : current.npmStatic) + .filter((entry) => entry.status === "refused") + .map((entry) => + npmStaticRefusalDiag( + entry.package, + entry.detail ?? "its static compilation was refused", + current.npmImportSites.get(entry.package) ?? { file: entryPath, start: 0, end: 0 }, + ), + ), + ], + runtimeFences: [], + stats, + }); + const fe: Frontend = { + preflight: [...first.preflight], + npmStatic: [...first.npmStatic], + npmImportSites: new Map(first.npmImportSites), + entryText: () => current.entryText(), + entryExports: () => current.entryExports(), + entryContract: () => current.entryContract(), + sourceTexts: () => current.sourceTexts(), + sourceOverlays: () => current.sourceOverlays(), + dispose: () => current.dispose(), + lower: (opts) => { + const coverage = opts.coverage === true; + for (;;) { + const result = current.lower(opts); + const culprits = new Map(); + let texts: Map | null = null; + for (const d of result.diagnostics) { + const owner = sourceOverlayOfFile(d.loc.file); + if (owner === null || !isSourceOverlayFile(d.loc.file) || culprits.has(owner.key)) + continue; + texts ??= current.sourceTexts(); + const site = overlaySite( + d.loc.file, + owner.overlayDir, + texts.get(d.loc.file), + d.loc.start, + ); + culprits.set(owner.key, `${d.code} at ${site}: ${d.message}`); + } + if (culprits.size > 0) { + dropSourceOverlays(culprits); + adopt(reload()); + if (!frontendLowerable(current, coverage)) return unlowerable(result.stats, coverage); + continue; + } + // Coverage keeps its blockers rather than probing. + const used = frontendOverlayKeys(current); + if (coverage || result.diagnostics.length === 0 || used.length === 0) return result; + const snapshot = snapshotSourceOverlayDrops(); + const reason = surfaceReason("compile", result.diagnostics); + for (const key of used) dropSourceOverlay(key, reason); + const probe = reload(); + if (frontendLowerable(probe, false)) { + const lowered = probe.lower(opts); + if (lowered.diagnostics.length === 0) { + adopt(probe); + return lowered; + } + } + probe.dispose(); + restoreSourceOverlayDrops(snapshot); + return result; + } + }, + }; + return fe; +} + export function runFrontend( entryPath: string, loadProgram: ProgramLoader, @@ -227,9 +453,15 @@ export function runFrontend( // selections bypass eligibility heuristics, but never compilation checks. const explicit: readonly string[] = npmStatic !== undefined && typeof npmStatic !== "string" ? npmStatic : []; - return loadFrontend(entryPath, trackedLoader, "lib", externalTypes, [ - ...new Set([...libraryNpmStatic, ...explicit]), - ]); + const load = (): Frontend => + loadFrontend(entryPath, trackedLoader, "lib", externalTypes, [ + ...new Set([...libraryNpmStatic, ...explicit]), + ]); + beginSourceOverlayCompile(); + const first = load(); + return sourceOverlaysActive() + ? withSourceOverlayLoweringFallback(first, load, entryPath) + : first; } catch (error) { for (const load of active) { // Preserve the original failure and attempt every remaining cleanup. @@ -266,24 +498,34 @@ function loadFrontend( if (npmStatic === "auto" || npmStatic === "lib") { const scout = loadProgram(entryPath, { externalTypes }); let retained = false; + let overlayCulprits = new Map(); try { const scoutPreflight = checkPreflight(scout); - requested = - npmStatic === "lib" - ? [...libraryNpmStatic, ...detectAutoPackages(scout, statuses, "lib", judged, npmSites)] - : detectAutoPackages(scout, statuses); - // With no package to opt in, the scout already IS the final frontend: - // same roots, resolution posture, preflight, and module order. Retain it - // instead of spawning a second tsgo server and checking the whole graph - // again — the common library-mode path has no bare npm imports. - if (requested.length === 0) { - reusableScout = scout; - reusablePreflight = scoutPreflight; - retained = true; + // --sources: overlays that already fail here fall back before any + // package selection depends on them. + overlayCulprits = sourceOverlayCulprits(scout, scoutPreflight); + if (overlayCulprits.size === 0) { + requested = + npmStatic === "lib" + ? [...libraryNpmStatic, ...detectAutoPackages(scout, statuses, "lib", judged, npmSites)] + : detectAutoPackages(scout, statuses); + // With no package to opt in, the scout already IS the final frontend: + // same roots, resolution posture, preflight, and module order. Retain it + // instead of spawning a second tsgo server and checking the whole graph + // again — the common library-mode path has no bare npm imports. + if (requested.length === 0) { + reusableScout = scout; + reusablePreflight = scoutPreflight; + retained = true; + } } } finally { if (!retained) scout.dispose(); } + if (overlayCulprits.size > 0) { + dropSourceOverlays(overlayCulprits); + return loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + } } else if (npmStatic !== undefined) { requested = [...new Set(npmStatic)]; } @@ -434,6 +676,49 @@ function loadFrontend( } } + // --sources: npm packages joining the program can expose new overlay + // failures (a published CommonJS dependency's require() of an augmented + // package, a preflight error only the full graph reaches). Drop those + // instances and restart the whole selection without them. + const lateCulprits = sourceOverlayCulprits(load, preflight); + if (lateCulprits.size > 0) { + dropSourceOverlays(lateCulprits); + load.dispose(); + return loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + } + const overlaysUsed = usedSourceOverlays(load); + // The preflight last resort: errors no overlay is blamed for may still + // come from typing the program against the overlays' source surface. + // Probe with every used overlay dropped and keep the probe only when it + // preflights cleanly. + if (overlaysUsed.size > 0 && preflight.length > 0) { + const snapshot = snapshotSourceOverlayDrops(); + const reason = surfaceReason("typecheck", preflight); + for (const key of overlaysUsed) dropSourceOverlay(key, reason); + const probe = loadFrontend(entryPath, loadProgram, npmStatic, externalTypes, libraryNpmStatic); + if ( + probe.preflight.length === 0 && + !probe.npmStatic.some((entry) => entry.status === "refused") + ) { + load.dispose(); + return probe; + } + probe.dispose(); + restoreSourceOverlayDrops(snapshot); + } + for (const instance of sourceOverlaySet()?.instances ?? []) { + const dropped = sourceOverlayDropReason(instance.key); + if (dropped !== null) { + statuses.push({ + package: `${instance.name}@${instance.version}`, + status: "source-fallback", + detail: `source overlay: ${dropped}`, + }); + } else if (overlaysUsed.has(instance.key)) { + statuses.push({ package: `${instance.name}@${instance.version}`, status: "source" }); + } + } + const finalLoad = load; return { preflight, @@ -482,6 +767,7 @@ function loadFrontend( }), npmStatic: statuses, npmImportSites: npmSites, + sourceOverlays: () => sourceOverlayReport(overlaysUsed), dispose: finalLoad.dispose, }; } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 3a4ec16479..991ce23ee7 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -59,6 +59,8 @@ import { projectDtsRuntimeSibling, resolveBareModule, resolveProjectModule, + resolveSourceOverlayModule, + sourceOverlayPaths, resolveTypeDirective, setProjectPathMappings, setProjectRealm, @@ -87,7 +89,6 @@ import type { NpmStaticDeclarationProperties, NpmStaticOverloadSignature, } from "./npm-static-declaration-syntax.js"; -import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js"; import { ambientDtsPath, @@ -693,13 +694,15 @@ function loadProgram7( // so tsgo's OWN resolution of the bare specifiers lands on the same // source files the preflight resolver answers — the checker types the // driver against the package's real TypeScript, not its shipped .d.ts. - const provenance = provenancePaths(); - if (provenance !== null || externalTypes.size > 0) { + // --sources: the active overlays' exported specifiers map to their + // source files (resolveSourceOverlayModule answers the same files). + const overlays = sourceOverlayPaths(); + if (overlays !== null || externalTypes.size > 0) { const paths: Record = { ...(configuredPaths as Record | undefined), - // Keep an index-signature source for native lowering when provenance is absent. + // Keep an index-signature source for native lowering when no overlay is active. // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread - ...(provenance ?? {}), + ...(overlays ?? {}), }; for (const [specifier, declarationPath] of externalTypes) { paths[specifier] = [declarationPath]; @@ -3251,10 +3254,10 @@ function resolveNpmImport7( if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { return null; } - // --provenance-sources: a registered specifier is NOT an npm import — - // its attested source compiles as program modules (resolveProjectModule - - if (provenanceEntryFor(specifier) !== null) return null; + // --sources: an import landing in an augmented instance is NOT an npm + // import — its source compiles as program modules (resolveProjectModule + // answers it). + if (resolveSourceOverlayModule(fromFileName, specifier, resolutionKind) !== null) return null; const resolved = resolveBareModule(fromFileName, specifier, undefined, resolutionKind); if (!resolved) return null; if (!isNodeModulesPath(resolved.typesFile)) { diff --git a/packages/compiler/src/frontend/provenance-core.ts b/packages/compiler/src/frontend/provenance-core.ts index 646aa2d53b..208a040213 100644 --- a/packages/compiler/src/frontend/provenance-core.ts +++ b/packages/compiler/src/frontend/provenance-core.ts @@ -424,6 +424,7 @@ export async function resolveProvenanceSourcesWithParser( repo, commit, dir, + installedDir: installed.dir, entries, }; if (pkg.sourceVersion !== undefined) { diff --git a/packages/compiler/src/frontend/provenance-registry.ts b/packages/compiler/src/frontend/provenance-registry.ts index 745bb04475..17d5203d5e 100644 --- a/packages/compiler/src/frontend/provenance-registry.ts +++ b/packages/compiler/src/frontend/provenance-registry.ts @@ -1,3 +1,7 @@ +import { realpathSync } from "node:fs"; +import { relative } from "node:path"; +import { installSourceOverlays, type SourceOverlayInstance } from "./source-overlays.js"; + export interface ProvenancePackageSource { /** Package name ("cookie"). */ name: string; @@ -15,6 +19,8 @@ export interface ProvenancePackageSource { /** Absolute path of the package's directory inside the cached source * tree (== the tree root except in monorepos). */ dir: string; + /** The installed (published) package directory the sources replace. */ + installedDir: string; /** specifier → absolute mapped source file ("cookie" → …/src/index.ts). */ entries: Record; } @@ -26,64 +32,56 @@ export interface ProvenanceSources { notes: string[]; } -interface RegistryState { - bySpecifier: Map; - packageDirs: string[]; - sources: ProvenanceSources; -} - -let state: RegistryState | null = null; - -/** Installs the resolved provenance sources for the current compile. - * Passing null (or an empty set) clears the registry. */ +/** Installs the pilot's resolved provenance sources for the current + * compile as a source-overlay set (source-overlays.ts) — the pilot is one + * more overlay producer, keyed like a manifest by each package's installed + * directory. Passing null clears the overlay registry. The set never joins + * a build cache: the pilot resolves sources from the network. */ export function setProvenanceSources(sources: ProvenanceSources | null): void { - if (sources === null || sources.packages.length === 0) { - state = sources === null ? null : { bySpecifier: new Map(), packageDirs: [], sources }; + if (sources === null) { + installSourceOverlays(null); return; } - const bySpecifier = new Map(); - const packageDirs: string[] = []; + const instances: SourceOverlayInstance[] = []; + const notes = [...sources.notes]; for (const pkg of sources.packages) { - packageDirs.push(pkg.dir.endsWith("/") ? pkg.dir : `${pkg.dir}/`); + const key = realDirOrNull(pkg.installedDir); + const overlayDir = realDirOrNull(pkg.dir); + if (key === null || overlayDir === null) { + notes.push(`${pkg.name}: installed or source directory missing; published package used`); + continue; + } + const exports: Record = {}; + const nameLen = pkg.name.split("/").length; for (const [spec, file] of Object.entries(pkg.entries)) { - bySpecifier.set(spec, file); + const parts = spec.split("/"); + const subpath = parts.length === nameLen ? "." : `./${parts.slice(nameLen).join("/")}`; + exports[subpath] = `./${relative(overlayDir, realpathSync(file)).split("\\").join("/")}`; } + instances.push({ + key, + name: pkg.name, + version: pkg.version, + overlayDir, + exports, + origin: `${pkg.repo + .replace(/^git\+/, "") + .replace(/^https:\/\//, "") + .replace(/@refs\/.*$/, "")} @ ${pkg.commit.slice(0, 12)}`, + }); } - state = { bySpecifier, packageDirs, sources }; -} - -/** The active sources (for reports), or null when the flag is off. */ -export function provenanceSources(): ProvenanceSources | null { - return state?.sources ?? null; + installSourceOverlays({ + origin: "--provenance-sources", + cacheIdentity: null, + instances, + notes, + }); } -/** The mapped source entry for a bare specifier, or null. */ -export function provenanceEntryFor(specifier: string): string | null { - return state?.bySpecifier.get(specifier) ?? null; -} - -/** True when any provenance package is registered. */ -export function provenanceActive(): boolean { - return state !== null && state.bySpecifier.size > 0; -} - -/** True when `fileName` lives inside a registered package's source tree — - * the gate for the third-party-source policies (the pure-annotated - * dead-const elision, the per-file statement attribution). */ -export function isProvenanceSourceFile(fileName: string): boolean { - if (state === null) return false; - for (const dir of state.packageDirs) { - if (fileName.startsWith(dir)) return true; +function realDirOrNull(path: string): string | null { + try { + return realpathSync(path).split("\\").join("/").replace(/\/+$/, ""); + } catch { + return null; } - return false; -} - -/** tsconfig "paths" for the registered entries — loadProgram feeds these - * to tsgo so the checker resolves registered bare specifiers to the same - * source files the preflight resolver answers. */ -export function provenancePaths(): Record | null { - if (state === null || state.bySpecifier.size === 0) return null; - const paths: Record = {}; - for (const [spec, file] of state.bySpecifier) paths[spec] = [file]; - return paths; } diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index db440c1996..b4f56ebb63 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -21,7 +21,12 @@ import { npmStaticPackageOfPath, npmStaticTransformPkgJson, } from "./npm-static.js"; -import { provenanceEntryFor } from "./provenance-registry.js"; +import { + activeSourceOverlayAt, + activeSourceOverlays, + noteSourceOverlayProblem, + sourceOverlayNameActive, +} from "./source-overlays.js"; import { trackedAccessibleEntries, trackedDirectoryExists, @@ -599,13 +604,6 @@ export function nearestInvalidPackageJsonPath(fromFile: string): string | null { * Relative specifiers, real node_modules packages, and builtins are other * resolvers' business; callers try those first. */ export function resolveProjectImport(fromFile: string, specifier: string): string | null { - // --provenance-sources (flag-gated; the registry is empty otherwise): a - // registered bare specifier answers its attested SOURCE entry — the one - // chokepoint that makes preflight's user-module edges, the module - // order, and the CJS link check all agree the package compiles as - - const provenance = provenanceEntryFor(specifier); - if (provenance !== null) return normalizeResolvedPath(provenance); const viaPaths = resolveViaProjectPaths(specifier); if (viaPaths !== null) return normalizeResolvedPath(viaPaths); const pkgDir = nearestPkgDir(dirname(resolve(fromFile))); @@ -653,6 +651,11 @@ export function resolveProjectModule(fromFile: string, specifier: string): strin } if (specifier.startsWith("node:")) return null; return ( + // --sources: an import landing in an augmented instance answers the + // instance's SOURCE entry — the one chokepoint that makes preflight's + // user-module edges, the module order, and the CJS link check agree + // the package compiles as program modules. + resolveSourceOverlayModule(fromFile, specifier) ?? resolveProjectImport(fromFile, specifier) ?? resolveWorkspaceSourceModule(fromFile, specifier)?.typesFile ?? null @@ -958,6 +961,105 @@ export function resolveBareModule( return npmStatic ? passOnce("js") : (passOnce("types") ?? passOnce("js")); } +/** The conditions an overlay's exports resolve with. Overlays point every + * condition at source, so "types" is accepted alongside Node's runtime + * conditions for the edge kind. */ +const OVERLAY_IMPORT_CONDITIONS = new Set(["types", "import", "node", "default"]); +const OVERLAY_REQUIRE_CONDITIONS = new Set(["types", "require", "node", "default"]); + +/** --sources: resolves a bare specifier to an augmented package + * instance's SOURCE file, or null. Node's node_modules walk from the + * importing file finds the package directory; when its realpath is an + * active instance, the subpath resolves against the instance's exports + * inside its overlay directory. Problems that would make the checker and + * this resolver disagree (a missing export, a second installed copy the + * checker's global "paths" would also redirect) are noted for the + * frontend, which drops the instance and reloads. */ +export function resolveSourceOverlayModule( + fromFile: string, + specifier: string, + resolutionKind: "import" | "require" = "import", +): string | null { + if ( + isRelativeSpecifier(specifier) || + isAbsolute(specifier) || + specifier.startsWith("node:") || + specifier.startsWith("#") + ) { + return null; + } + const pkgName = packageNameOfSpecifier(specifier); + if (!sourceOverlayNameActive(pkgName)) return null; + const rest = specifier.slice(pkgName.length).replace(/^\//, ""); + const subpath = rest === "" ? "." : `./${rest}`; + for (let dir = dirname(resolve(fromFile)); ;) { + const candidate = join(dir, "node_modules", pkgName); + if (isDirectory(candidate)) { + const real = realpathOr(candidate); + const instance = activeSourceOverlayAt(real); + if (instance === null) { + for (const other of activeSourceOverlays()) { + if (other.name !== pkgName) continue; + noteSourceOverlayProblem( + other.key, + `ambiguous-name: another installed copy of ${pkgName} (${real}) is imported from ${fromFile}`, + ); + } + return null; + } + const target = resolveExports( + instance.exports, + subpath, + resolutionKind === "require" ? OVERLAY_REQUIRE_CONDITIONS : OVERLAY_IMPORT_CONDITIONS, + ); + if (target === null) { + noteSourceOverlayProblem(instance.key, `'${specifier}' is not exported by the overlay`); + return null; + } + const file = join(instance.overlayDir, target); + if (!isFile(file)) { + noteSourceOverlayProblem( + instance.key, + `'${specifier}' maps to ${target}, which does not exist in the overlay`, + ); + return null; + } + return normalizeResolvedPath(file); + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** tsconfig "paths" for the active overlays: each exported subpath of an + * instance maps its bare specifier to the instance's source file, so the + * checker resolves the same files resolveSourceOverlayModule answers. + * Global by name, which is why names with several augmented instances + * start dropped (source-overlays.ts). */ +export function sourceOverlayPaths(): Record | null { + const active = activeSourceOverlays(); + if (active.length === 0) return null; + const paths: Record = {}; + for (const instance of active) { + const ex = instance.exports; + const keys = + ex !== null && + typeof ex === "object" && + !Array.isArray(ex) && + Object.keys(ex).every((k) => k.startsWith(".")) + ? Object.keys(ex) + : ["."]; + for (const key of keys) { + const target = resolveExports(ex, key, OVERLAY_IMPORT_CONDITIONS); + if (target === null) continue; + const specifier = key === "." ? instance.name : `${instance.name}/${key.slice(2)}`; + paths[specifier] = [normalizeResolvedPath(join(instance.overlayDir, target))]; + } + } + return paths; +} + export function resolveWorkspaceSourceModule( fromFile: string, specifier: string, diff --git a/packages/compiler/src/frontend/source-overlays.ts b/packages/compiler/src/frontend/source-overlays.ts new file mode 100644 index 0000000000..f40c2971c1 --- /dev/null +++ b/packages/compiler/src/frontend/source-overlays.ts @@ -0,0 +1,380 @@ +/* Source overlays: TypeScript source trees that replace installed npm + * packages' published JavaScript (`scriptc build --sources`). + * + * A package manager (scriptc-pm) materializes each augmented package + * instance's upstream source into an overlay directory and describes the + * set in `.scriptc-pm/manifest.json`. The compiler never fetches or + * verifies anything: it reads the manifest, keys every instance by the + * REALPATH of its installed directory (so two installed versions of one + * name are distinct instances), and the resolver redirects bare imports + * that land in an instance's installed directory to the instance's + * `exports` inside its overlay directory (resolve.ts, + * resolveSourceOverlayModule). Overlay files live outside node_modules, so + * they compile as program modules. + * + * Every instance can fall back: the frontend drops an instance's redirect + * (pipeline.ts) when its source fails preflight or lowering, when a + * require() edge targets it, or when the checker and the resolver could + * disagree about it. The package then compiles from its published JS + * exactly as it would without --sources. Drops reset per compile. */ + +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +/** One augmented package instance, as the compiler uses it. */ +export interface SourceOverlayInstance { + /** realpath(installedDir): the instance identity. */ + key: string; + name: string; + version: string; + /** realpath(overlayDir), without a trailing slash. */ + overlayDir: string; + /** package.json-shaped "exports" resolved against overlayDir. */ + exports: unknown; + /** Human-readable source origin for reports ("github.com/o/r @ abc123"), + * or null when the producer did not say. */ + origin: string | null; +} + +/** A complete overlay set, ready to install for a compile. */ +export interface SourceOverlaySet { + /** Where the set came from (a manifest path, or "--provenance-sources"). */ + origin: string; + /** Build-cache identity for the set, or null when the set cannot be + * cached (the network-fetching --provenance-sources pilot). */ + cacheIdentity: string | null; + instances: SourceOverlayInstance[]; + /** Producer notes (manifest fallbacks, skipped instances). */ + notes: string[]; +} + +/** The per-package lines of the coverage report's source section. */ +export interface SourceOverlayReport { + origin: string; + packages: { + /** The instance key (realpath of its installed directory). */ + key: string; + name: string; + version: string; + dir: string; + origin: string | null; + /** Null when the overlay compiled; the reason otherwise. */ + fallback: string | null; + }[]; + notes: string[]; +} + +export const SOURCE_MANIFEST_RELATIVE_PATH = ".scriptc-pm/manifest.json"; + +interface OverlayState { + set: SourceOverlaySet; + byKey: Map; + /** Install-time drops (ambiguous names) — the starting point of every + * compile's drop set. */ + initialDrops: Map; + drops: Map; + /** Problems the resolver noticed while answering (missing export + * targets, checker disagreement), drained by the frontend loop. */ + problems: Map; +} + +let state: OverlayState | null = null; + +/** Installs `set` for the following compiles; null clears it. Names with + * more than one augmented instance cannot be redirected for the checker + * until it resolves per instance (tsgo receives one global `paths` entry + * per name), so those instances start dropped. */ +export function installSourceOverlays(set: SourceOverlaySet | null): void { + if (set === null) { + state = null; + return; + } + const byKey = new Map(); + const byName = new Map(); + for (const instance of set.instances) { + byKey.set(instance.key, instance); + const list = byName.get(instance.name) ?? []; + list.push(instance); + byName.set(instance.name, list); + } + const initialDrops = new Map(); + for (const [name, list] of byName) { + if (list.length < 2) continue; + for (const instance of list) { + initialDrops.set( + instance.key, + `ambiguous-name: ${list.length} augmented instances of ${name} (only single-instance names are redirected)`, + ); + } + } + state = { set, byKey, initialDrops, drops: new Map(initialDrops), problems: new Map() }; +} + +/** Restores the install-time drop set; called once per compile, before + * the first program load. */ +export function beginSourceOverlayCompile(): void { + if (state === null) return; + state.drops = new Map(state.initialDrops); + state.problems.clear(); +} + +export function sourceOverlaySet(): SourceOverlaySet | null { + return state?.set ?? null; +} + +/** True when at least one instance is currently redirected. */ +export function sourceOverlaysActive(): boolean { + return state !== null && state.drops.size < state.byKey.size; +} + +/** The redirected (not dropped) instances. */ +export function activeSourceOverlays(): SourceOverlayInstance[] { + if (state === null) return []; + const s = state; + return s.set.instances.filter((instance) => !s.drops.has(instance.key)); +} + +/** True when some active instance is named `name` — the cheap pre-check + * before the resolver walks node_modules. */ +export function sourceOverlayNameActive(name: string): boolean { + if (state === null) return false; + for (const instance of state.set.instances) { + if (instance.name === name && !state.drops.has(instance.key)) return true; + } + return false; +} + +/** The active instance whose installed directory realpaths to `realDir`. */ +export function activeSourceOverlayAt(realDir: string): SourceOverlayInstance | null { + const instance = state?.byKey.get(realDir); + return instance !== undefined && !state!.drops.has(instance.key) ? instance : null; +} + +/** The instance (active or dropped) whose overlay directory contains + * `file`, or null. */ +export function sourceOverlayOfFile(file: string): SourceOverlayInstance | null { + if (state === null) return null; + const norm = file.split("\\").join("/"); + for (const instance of state.set.instances) { + const dir = instance.overlayDir; + if (norm.startsWith(dir) && norm[dir.length] === "/") return instance; + } + return null; +} + +/** True when `file` belongs to an active overlay — the gate for the + * third-party-source lowering policies (the @__PURE__ dead-const elision, + * per-file statement attribution). */ +export function isSourceOverlayFile(file: string): boolean { + const instance = sourceOverlayOfFile(file); + return instance !== null && !state!.drops.has(instance.key); +} + +export function dropSourceOverlay(key: string, reason: string): void { + if (state === null || !state.byKey.has(key) || state.drops.has(key)) return; + state.drops.set(key, reason); +} + +/** The current drop set, for a probe that may need to undo its drops. */ +export function snapshotSourceOverlayDrops(): Map { + return new Map(state?.drops ?? []); +} + +export function restoreSourceOverlayDrops(snapshot: ReadonlyMap): void { + if (state !== null) state.drops = new Map(snapshot); +} + +export function noteSourceOverlayProblem(key: string, reason: string): void { + if (state === null || state.drops.has(key) || state.problems.has(key)) return; + state.problems.set(key, reason); +} + +/** Drains the problems the resolver noted since the last call. */ +export function takeSourceOverlayProblems(): Map { + if (state === null) return new Map(); + const out = new Map(state.problems); + state.problems.clear(); + return out; +} + +export function sourceOverlayDropReason(key: string): string | null { + return state?.drops.get(key) ?? null; +} + +/** The instances the last compile dropped, with their reasons. */ +export function sourceOverlayFallbacks(): { name: string; version: string; reason: string }[] { + if (state === null) return []; + const out: { name: string; version: string; reason: string }[] = []; + for (const instance of state.set.instances) { + const reason = state.drops.get(instance.key); + if (reason !== undefined) out.push({ name: instance.name, version: instance.version, reason }); + } + return out; +} + +/** The build-cache identity: undefined when no set is installed, null when + * the installed set cannot be cached. */ +export function sourceOverlayCacheIdentity(): string | null | undefined { + if (state === null) return undefined; + return state.set.cacheIdentity; +} + +/** Report rows: every instance the program reached (`used` holds the keys + * of active instances with files in the final program) plus every dropped + * instance, in manifest order. */ +export function sourceOverlayReport(used: ReadonlySet): SourceOverlayReport | null { + if (state === null) return null; + const packages: SourceOverlayReport["packages"] = []; + for (const instance of state.set.instances) { + const dropped = state.drops.get(instance.key); + // Ambiguous-name drops are reported even when unused: they explain + // why an augmented package compiled from its published JS. + if (dropped === undefined && !used.has(instance.key)) continue; + packages.push({ + key: instance.key, + name: instance.name, + version: instance.version, + dir: instance.overlayDir, + origin: instance.origin, + fallback: dropped ?? null, + }); + } + return { origin: state.set.origin, packages, notes: state.set.notes }; +} + +/* ── the manifest (schema 1) ──────────────────────────────────────────── */ + +/** Walks up from the entry's directory to the nearest + * `.scriptc-pm/manifest.json`. */ +export function findSourceManifest(entryPath: string): string | null { + for (let dir = dirname(resolve(entryPath)); ;) { + const candidate = join(dir, SOURCE_MANIFEST_RELATIVE_PATH); + try { + if (statSync(candidate).isFile()) return candidate; + } catch { + /* keep walking */ + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +function realDirectory(path: string): string | null { + try { + const real = realpathSync(path); + return statSync(real).isDirectory() ? real.split("\\").join("/").replace(/\/+$/, "") : null; + } catch { + return null; + } +} + +function originOf(source: unknown): string | null { + if (source === null || typeof source !== "object") return null; + const s = source as { repo?: unknown; commit?: unknown; subdir?: unknown }; + if (typeof s.repo !== "string") return null; + const commit = typeof s.commit === "string" ? ` @ ${s.commit.slice(0, 12)}` : ""; + const subdir = typeof s.subdir === "string" && s.subdir !== "" ? ` (${s.subdir})` : ""; + return `${s.repo}${commit}${subdir}`; +} + +/** Reads and validates a schema-1 manifest. Throws an Error naming the + * first problem for an unreadable or malformed manifest; instances whose + * directories are missing are skipped with a note (they compile from + * published JS). */ +export function loadSourceManifest(manifestPath: string): SourceOverlaySet { + const path = resolve(manifestPath); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch { + throw new Error(`cannot read source manifest ${path}`); + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (e) { + throw new Error( + `source manifest ${path} is not valid JSON (${e instanceof Error ? e.message : String(e)})`, + ); + } + const bad = (what: string): Error => new Error(`source manifest ${path}: ${what}`); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw bad("expected a JSON object"); + } + const doc = raw as Record; + if (doc["schema"] !== 1) { + throw bad(`unsupported schema ${JSON.stringify(doc["schema"])} (this scriptc reads schema 1)`); + } + const lockDigest = doc["lockDigest"]; + if (typeof lockDigest !== "string" || lockDigest === "") { + throw bad("missing lockDigest"); + } + const rawInstances = doc["instances"]; + if (!Array.isArray(rawInstances)) throw bad("missing instances array"); + const instances: SourceOverlayInstance[] = []; + const notes: string[] = []; + const seen = new Set(); + for (let index = 0; index < rawInstances.length; index++) { + const entry: unknown = rawInstances[index]; + if (entry === null || typeof entry !== "object") + throw bad(`instances[${index}] is not an object`); + const e = entry as Record; + for (const field of ["installedDir", "name", "version", "overlayDir"]) { + if (typeof e[field] !== "string" || e[field] === "") { + throw bad(`instances[${index}].${field} must be a non-empty string`); + } + } + const installedDir = e["installedDir"] as string; + const overlayDir = e["overlayDir"] as string; + const name = e["name"] as string; + const version = e["version"] as string; + if (!isAbsolute(installedDir) || !isAbsolute(overlayDir)) { + throw bad(`instances[${index}] directories must be absolute paths`); + } + const exportsField = e["exports"]; + if ( + exportsField === undefined || + exportsField === null || + (typeof exportsField !== "string" && typeof exportsField !== "object") + ) { + throw bad(`instances[${index}].exports must be a string or an object`); + } + const key = realDirectory(installedDir); + if (key === null) { + notes.push(`${name}@${version}: installed directory ${installedDir} does not exist; skipped`); + continue; + } + const realOverlay = realDirectory(overlayDir); + if (realOverlay === null) { + notes.push(`${name}@${version}: overlay directory ${overlayDir} does not exist; skipped`); + continue; + } + if (seen.has(key)) throw bad(`instances[${index}] repeats installed directory ${key}`); + seen.add(key); + instances.push({ + key, + name, + version, + overlayDir: realOverlay, + exports: exportsField, + origin: originOf(e["source"]), + }); + } + const fallbacks = doc["fallbacks"]; + if (Array.isArray(fallbacks)) { + for (const f of fallbacks) { + if (f === null || typeof f !== "object") continue; + const fb = f as { name?: unknown; version?: unknown; reason?: unknown }; + if (typeof fb.name !== "string") continue; + const version = typeof fb.version === "string" ? `@${fb.version}` : ""; + const reason = typeof fb.reason === "string" ? fb.reason : "no reason given"; + notes.push(`${fb.name}${version}: published JS (${reason})`); + } + } + // The lock digest names the dependency and source state the manifest was + // generated from; the bytes digest also covers hand-edited manifests. + const cacheIdentity = `${lockDigest}\0${createHash("sha256").update(text).digest("hex")}`; + return { origin: path, cacheIdentity, instances, notes }; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index c3009665a0..5ced3ca435 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -109,7 +109,7 @@ import { writeTextParts } from "./text-parts.js"; import { validateModule } from "./ir/validate.js"; import { loadProgram } from "./frontend/program-node.js"; import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js"; -import { provenanceSources } from "./frontend/provenance-registry.js"; +import { sourceOverlayCacheIdentity } from "./frontend/source-overlays.js"; import { clearResolveCaches } from "./frontend/resolve.js"; import { loadFfiProfile, type FfiProfile } from "./ffi/ffi-manifest.js"; import { executableLinkFeatures } from "./backend/executable-features.js"; @@ -290,6 +290,13 @@ export { type ProvenancePackageSource, type ProvenanceSources, } from "./frontend/provenance-registry.js"; +export { + findSourceManifest, + installSourceOverlays, + loadSourceManifest, + sourceOverlayFallbacks, + type SourceOverlaySet, +} from "./frontend/source-overlays.js"; export * as ir from "./ir/api.js"; /** Clang may print every warning from the generated/runtime translation @@ -336,7 +343,7 @@ export async function analyzeCached( ): Promise { entryPath = resolve(entryPath); const root = await prepareBuildCacheRoot(buildCacheRoot()); - if (root === null || provenanceSources() !== null) return analyze(entryPath, opts); + if (root === null) return analyze(entryPath, opts); const directory = join(root, "coverage-v1"); const implementation = await coverageCompilerIdentity(directory); if (implementation === null) return analyze(entryPath, opts); @@ -1001,9 +1008,10 @@ async function compileTracked( }; } const cacheRoot = - outputKind === "exe" && provenanceSources() === null + outputKind === "exe" && sourceOverlayCacheIdentity() !== null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + const sourcesIdentity = sourceOverlayCacheIdentity() ?? undefined; let earlyCacheOptions: EarlyExecutableCacheOptions | null = null; if (outputKind === "exe") { const helperObjectRoute = @@ -1049,6 +1057,7 @@ async function compileTracked( nodeVersion: process.version, implementation: implementation.digest, implementationDependencies: implementation.dependencies, + ...(sourcesIdentity !== undefined ? { sources: sourcesIdentity } : {}), }; } const earlyHit = @@ -1650,7 +1659,8 @@ async function compileLibraryTracked( } const cacheRoot = - provenanceSources() === null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + sourceOverlayCacheIdentity() !== null ? await prepareBuildCacheRoot(buildCacheRoot()) : null; + const sourcesIdentity = sourceOverlayCacheIdentity() ?? undefined; const earlyCacheOptions: EarlyLibraryCacheOptions = { profilePath: opts.profilePath, profileBytes: profile.profileBytes, @@ -1663,6 +1673,7 @@ async function compileLibraryTracked( compiler: [process.env["SCRIPTC_CC"] ?? "clang"], nodeVersion: process.version, implementation: await libraryFrontendImplementationFingerprint(), + ...(sourcesIdentity !== undefined ? { sources: sourcesIdentity } : {}), }; const earlyHit = await readEarlyLibraryCache( cacheRoot, diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 1b65024e03..c9fca89093 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -91,6 +91,8 @@ export interface EarlyLibraryCacheOptions { * frontend classification. */ nodeVersion: string; implementation: string; + /** --sources: the overlay set's identity. Omitted without --sources. */ + sources?: string; } export interface EarlyLibraryCacheHit { @@ -154,6 +156,7 @@ function cacheKey(options: EarlyLibraryCacheOptions): string { options.compiler.join("\x1f"), options.nodeVersion, options.implementation, + ...(options.sources !== undefined ? [`sources:${options.sources}`] : []), ]); } diff --git a/packages/compiler/src/native/compiler.test.ts b/packages/compiler/src/native/compiler.test.ts index 8203271ebe..97bf957811 100644 --- a/packages/compiler/src/native/compiler.test.ts +++ b/packages/compiler/src/native/compiler.test.ts @@ -84,6 +84,7 @@ test.each([ sourceTexts: () => new Map(), npmStatic: [], npmImportSites: new Map(), + sourceOverlays: () => null, dispose: () => {}, lower: () => ({ module, @@ -222,6 +223,7 @@ test.each([false, true])( sourceTexts: () => new Map(), npmStatic: [], npmImportSites: new Map(), + sourceOverlays: () => null, dispose, lower: () => ({ module, diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 18635c8c75..11b780c5ce 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -22,7 +22,7 @@ import { type FrontendInputExclusions, trackedReadFile, } from "../frontend/input-tracker.js"; -import { provenanceSources } from "../frontend/provenance-registry.js"; +import { sourceOverlayCacheIdentity } from "../frontend/source-overlays.js"; import { prepareLibrary } from "../library/prepare.js"; import type { LibraryProfile } from "../library/library-profile.js"; import type { IrModule } from "../ir/ir.js"; @@ -234,13 +234,15 @@ export function prepareNativeExecutable( const exclusions = { outputPaths, outputDirectories: [...directories] }; const irInput = process.env[BOOTSTRAP_IR_INPUT] ?? ""; let key: string | null = null; - if (cache !== null && irInput === "" && provenanceSources() === null) { + const sources = sourceOverlayCacheIdentity(); + if (cache !== null && irInput === "" && sources !== null) { try { key = contentDigest( JSON.stringify({ schema: 1, entry, options, + ...(sources !== undefined ? { sources } : {}), ffi, implementation: implementationIdentity(toolchain), target: toolchain.target.name, @@ -345,12 +347,14 @@ export function prepareNativeLibrary( } const exclusions: FrontendInputExclusions = { outputPaths, outputDirectories: [...directories] }; let key: string | null = null; - if (cache !== null && provenanceSources() === null) { + const sources = sourceOverlayCacheIdentity(); + if (cache !== null && sources !== null) { try { key = contentDigest( JSON.stringify({ schema: 1, kind: "library", + ...(sources !== undefined ? { sources } : {}), entry: profile.entry, profile: profile.profileBytes, options, diff --git a/tests/fixtures/source-overlays/project/emit.ts b/tests/fixtures/source-overlays/project/emit.ts new file mode 100644 index 0000000000..afd0f09a98 --- /dev/null +++ b/tests/fixtures/source-overlays/project/emit.ts @@ -0,0 +1,20 @@ +import mitt, { type Emitter } from "mitt"; + +type Events = { greet: string; count: number }; +const emitter: Emitter = mitt(); +const seen: string[] = []; +const onGreet = (name: string): void => { + seen.push(`hello ${name}`); +}; +emitter.on("greet", onGreet); +emitter.on("count", (n) => seen.push(`count ${n * 2}`)); +emitter.on("*", (type, payload) => seen.push(`* ${String(type)} ${JSON.stringify(payload)}`)); +emitter.emit("greet", "world"); +emitter.emit("count", 21); +emitter.off("greet", onGreet); +emitter.emit("greet", "again"); +console.log(seen.join("\n")); +console.log("handlers", emitter.all.size, emitter.all.get("greet")?.length ?? 0); +emitter.all.clear(); +emitter.emit("count", 1); +console.log("cleared", seen.length); diff --git a/tests/fixtures/source-overlays/project/flags.ts b/tests/fixtures/source-overlays/project/flags.ts new file mode 100644 index 0000000000..3a9cdfa56b --- /dev/null +++ b/tests/fixtures/source-overlays/project/flags.ts @@ -0,0 +1,17 @@ +import { InMemoryProvider, OpenFeature } from "@openfeature/server-sdk"; + +const provider = new InMemoryProvider({ + "new-ui": { variants: { on: true, off: false }, defaultVariant: "on", disabled: false }, + greeting: { + variants: { formal: "Good day", casual: "Hey" }, + defaultVariant: "casual", + disabled: false, + }, +}); +await OpenFeature.setProviderAndWait(provider); +const client = OpenFeature.getClient("demo"); +console.log("new-ui", await client.getBooleanValue("new-ui", false)); +console.log("greeting", await client.getStringValue("greeting", "Hello")); +const details = await client.getStringDetails("missing", "fallback"); +console.log("missing", details.value, details.reason, details.errorCode); +await OpenFeature.close(); diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE new file mode 100644 index 0000000000..96b3dc8fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js new file mode 100644 index 0000000000..cee9551822 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/index.js @@ -0,0 +1,1166 @@ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// src/hooks/hook-data.ts +var MapHookData = class { + constructor() { + this.data = /* @__PURE__ */ new Map(); + } + set(key, value) { + this.data.set(key, value); + } + get(key) { + return this.data.get(key); + } + has(key) { + return this.data.has(key); + } + delete(key) { + return this.data.delete(key); + } + clear() { + this.data.clear(); + } +}; + +// src/evaluation/evaluation.ts +var StandardResolutionReasons = { + /** + * The resolved value is static (no dynamic evaluation). + */ + STATIC: "STATIC", + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + DEFAULT: "DEFAULT", + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + TARGETING_MATCH: "TARGETING_MATCH", + /** + * The resolved value was the result of pseudorandom assignment. + */ + SPLIT: "SPLIT", + /** + * The resolved value was retrieved from cache. + */ + CACHED: "CACHED", + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + DISABLED: "DISABLED", + /** + * The reason for the resolved value could not be determined. + */ + UNKNOWN: "UNKNOWN", + /** + * The resolved value is non-authoritative or possibly out of date. + */ + STALE: "STALE", + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + ERROR: "ERROR" +}; +var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => { + ErrorCode2["PROVIDER_NOT_READY"] = "PROVIDER_NOT_READY"; + ErrorCode2["PROVIDER_FATAL"] = "PROVIDER_FATAL"; + ErrorCode2["FLAG_NOT_FOUND"] = "FLAG_NOT_FOUND"; + ErrorCode2["PARSE_ERROR"] = "PARSE_ERROR"; + ErrorCode2["TYPE_MISMATCH"] = "TYPE_MISMATCH"; + ErrorCode2["TARGETING_KEY_MISSING"] = "TARGETING_KEY_MISSING"; + ErrorCode2["INVALID_CONTEXT"] = "INVALID_CONTEXT"; + ErrorCode2["GENERAL"] = "GENERAL"; + return ErrorCode2; +})(ErrorCode || {}); + +// src/errors/open-feature-error-abstract.ts +var OpenFeatureError = class _OpenFeatureError extends Error { + constructor(message, options) { + super(message); + Object.setPrototypeOf(this, _OpenFeatureError.prototype); + this.name = "OpenFeatureError"; + this.cause = options == null ? void 0 : options.cause; + } +}; + +// src/errors/flag-not-found-error.ts +var FlagNotFoundError = class _FlagNotFoundError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _FlagNotFoundError.prototype); + this.name = "FlagNotFoundError"; + this.code = "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */; + } +}; + +// src/errors/general-error.ts +var GeneralError = class _GeneralError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _GeneralError.prototype); + this.name = "GeneralError"; + this.code = "GENERAL" /* GENERAL */; + } +}; + +// src/errors/invalid-context-error.ts +var InvalidContextError = class _InvalidContextError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _InvalidContextError.prototype); + this.name = "InvalidContextError"; + this.code = "INVALID_CONTEXT" /* INVALID_CONTEXT */; + } +}; + +// src/errors/parse-error.ts +var ParseError = class _ParseError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ParseError.prototype); + this.name = "ParseError"; + this.code = "PARSE_ERROR" /* PARSE_ERROR */; + } +}; + +// src/errors/provider-fatal-error.ts +var ProviderFatalError = class _ProviderFatalError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ProviderFatalError.prototype); + this.name = "ProviderFatalError"; + this.code = "PROVIDER_FATAL" /* PROVIDER_FATAL */; + } +}; + +// src/errors/provider-not-ready-error.ts +var ProviderNotReadyError = class _ProviderNotReadyError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _ProviderNotReadyError.prototype); + this.name = "ProviderNotReadyError"; + this.code = "PROVIDER_NOT_READY" /* PROVIDER_NOT_READY */; + } +}; + +// src/errors/targeting-key-missing-error.ts +var TargetingKeyMissingError = class _TargetingKeyMissingError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _TargetingKeyMissingError.prototype); + this.name = "TargetingKeyMissingError"; + this.code = "TARGETING_KEY_MISSING" /* TARGETING_KEY_MISSING */; + } +}; + +// src/errors/type-mismatch-error.ts +var TypeMismatchError = class _TypeMismatchError extends OpenFeatureError { + constructor(message, options) { + super(message, options); + Object.setPrototypeOf(this, _TypeMismatchError.prototype); + this.name = "TypeMismatchError"; + this.code = "TYPE_MISMATCH" /* TYPE_MISMATCH */; + } +}; + +// src/errors/index.ts +var instantiateErrorByErrorCode = (errorCode, message) => { + switch (errorCode) { + case "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */: + return new FlagNotFoundError(message); + case "PARSE_ERROR" /* PARSE_ERROR */: + return new ParseError(message); + case "TYPE_MISMATCH" /* TYPE_MISMATCH */: + return new TypeMismatchError(message); + case "TARGETING_KEY_MISSING" /* TARGETING_KEY_MISSING */: + return new TargetingKeyMissingError(message); + case "INVALID_CONTEXT" /* INVALID_CONTEXT */: + return new InvalidContextError(message); + case "PROVIDER_NOT_READY" /* PROVIDER_NOT_READY */: + return new ProviderNotReadyError(message); + case "PROVIDER_FATAL" /* PROVIDER_FATAL */: + return new ProviderFatalError(message); + default: + return new GeneralError(message); + } +}; + +// src/provider/provider.ts +var ServerProviderStatus = /* @__PURE__ */ ((ServerProviderStatus2) => { + ServerProviderStatus2["NOT_READY"] = "NOT_READY"; + ServerProviderStatus2["READY"] = "READY"; + ServerProviderStatus2["ERROR"] = "ERROR"; + ServerProviderStatus2["STALE"] = "STALE"; + ServerProviderStatus2["FATAL"] = "FATAL"; + return ServerProviderStatus2; +})(ServerProviderStatus || {}); +var ClientProviderStatus = /* @__PURE__ */ ((ClientProviderStatus2) => { + ClientProviderStatus2["NOT_READY"] = "NOT_READY"; + ClientProviderStatus2["READY"] = "READY"; + ClientProviderStatus2["ERROR"] = "ERROR"; + ClientProviderStatus2["STALE"] = "STALE"; + ClientProviderStatus2["FATAL"] = "FATAL"; + ClientProviderStatus2["RECONCILING"] = "RECONCILING"; + return ClientProviderStatus2; +})(ClientProviderStatus || {}); + +// src/provider/multi-provider/errors.ts +var ErrorWithCode = class extends OpenFeatureError { + constructor(code, message) { + super(message); + this.code = code; + } +}; +var AggregateError = class _AggregateError extends GeneralError { + constructor(message, originalErrors) { + super(message); + this.originalErrors = originalErrors; + Object.setPrototypeOf(this, _AggregateError.prototype); + this.name = "AggregateError"; + } +}; +var constructAggregateError = (providerErrors) => { + const errorsWithSource = providerErrors.map(({ providerName, error }) => { + return { source: providerName, error }; + }).flat(); + const firstError = errorsWithSource[0]; + const message = firstError ? `Provider errors occurred: ${firstError.source}: ${firstError.error}` : "Provider errors occurred"; + return new AggregateError(message, errorsWithSource); +}; +var throwAggregateErrorFromPromiseResults = (result, providerEntries) => { + const errors = result.map((r, i) => { + if (r.status === "rejected") { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }).filter((val) => Boolean(val)); + if (errors.length) { + throw constructAggregateError(errors); + } +}; + +// src/provider/multi-provider/status-tracker.ts +var StatusTracker = class { + constructor(events, statusEnum, eventEnum) { + this.events = events; + this.statusEnum = statusEnum; + this.eventEnum = eventEnum; + this.providerStatuses = {}; + } + wrapEventHandler(providerEntry) { + var _a, _b, _c, _d, _e; + const provider = providerEntry.provider; + (_a = provider.events) == null ? void 0 : _a.addHandler(this.eventEnum.Error, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.ERROR, details); + }); + (_b = provider.events) == null ? void 0 : _b.addHandler(this.eventEnum.Stale, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.STALE, details); + }); + (_c = provider.events) == null ? void 0 : _c.addHandler(this.eventEnum.ConfigurationChanged, (details) => { + this.events.emit(this.eventEnum.ConfigurationChanged, details); + }); + (_d = provider.events) == null ? void 0 : _d.addHandler(this.eventEnum.Ready, (details) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.READY, details); + }); + const reconcilingEvent = this.eventEnum.Reconciling; + const reconcilingStatus = this.statusEnum.RECONCILING; + if (reconcilingEvent && reconcilingStatus) { + (_e = provider.events) == null ? void 0 : _e.addHandler(reconcilingEvent, (details) => { + this.changeProviderStatus(providerEntry.name, reconcilingStatus, details); + }); + } + } + providerStatus(name) { + return this.providerStatuses[name]; + } + getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(this.statusEnum.FATAL)) { + return this.statusEnum.FATAL; + } else if (statuses.includes(this.statusEnum.NOT_READY)) { + return this.statusEnum.NOT_READY; + } else if (statuses.includes(this.statusEnum.ERROR)) { + return this.statusEnum.ERROR; + } else if (statuses.includes(this.statusEnum.STALE)) { + return this.statusEnum.STALE; + } else if (this.statusEnum.RECONCILING && statuses.includes(this.statusEnum.RECONCILING)) { + return this.statusEnum.RECONCILING; + } + return this.statusEnum.READY; + } + changeProviderStatus(name, status, details) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === this.statusEnum.FATAL || newStatus === this.statusEnum.ERROR) { + this.events.emit(this.eventEnum.Error, details); + } else if (newStatus === this.statusEnum.STALE) { + this.events.emit(this.eventEnum.Stale, details); + } else if (newStatus === this.statusEnum.READY) { + this.events.emit(this.eventEnum.Ready, details); + } else { + const reconcilingEvent = this.eventEnum.Reconciling; + if (reconcilingEvent && this.statusEnum.RECONCILING && newStatus === this.statusEnum.RECONCILING) { + this.events.emit(reconcilingEvent, details); + } + } + } + } +}; + +// src/provider/multi-provider/strategies/base-evaluation-strategy.ts +var BaseEvaluationStrategy = class { + constructor(statusEnum) { + this.statusEnum = statusEnum; + this.runMode = "sequential"; + } + shouldEvaluateThisProvider(strategyContext, _evalContext) { + if (strategyContext.providerStatus === this.statusEnum.NOT_READY || strategyContext.providerStatus === this.statusEnum.FATAL) { + return false; + } + return true; + } + shouldEvaluateNextProvider(_strategyContext, _context, _result) { + return true; + } + shouldTrackWithThisProvider(strategyContext, _context, _trackingEventName, _trackingEventDetails) { + if (strategyContext.providerStatus === this.statusEnum.NOT_READY || strategyContext.providerStatus === this.statusEnum.FATAL) { + return false; + } + return true; + } + hasError(resolution) { + return "thrownError" in resolution || !!resolution.details.errorCode; + } + hasErrorWithCode(resolution, code) { + var _a; + return "thrownError" in resolution ? ((_a = resolution.thrownError) == null ? void 0 : _a.code) === code : resolution.details.errorCode === code; + } + collectProviderErrors(resolutions) { + var _a; + const errors = []; + for (const resolution of resolutions) { + if ("thrownError" in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, (_a = resolution.details.errorMessage) != null ? _a : "unknown error") + }); + } + } + return { errors }; + } + resolutionToFinalResult(resolution) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +}; + +// src/provider/multi-provider/strategies/first-match-strategy.ts +var FirstMatchStrategy = class extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext, context, result) { + if (this.hasErrorWithCode(result, "FLAG_NOT_FOUND" /* FLAG_NOT_FOUND */)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + determineFinalResult(strategyContext, context, resolutions) { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/provider/multi-provider/strategies/first-successful-strategy.ts +var FirstSuccessfulStrategy = class extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext, context, result) { + return this.hasError(result); + } + determineFinalResult(strategyContext, context, resolutions) { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/provider/multi-provider/strategies/comparison-strategy.ts +var ComparisonStrategy = class extends BaseEvaluationStrategy { + constructor(statusEnum, fallbackProvider, onMismatch) { + super(statusEnum); + this.fallbackProvider = fallbackProvider; + this.onMismatch = onMismatch; + this.runMode = "parallel"; + } + determineFinalResult(strategyContext, context, resolutions) { + var _a; + let value; + let fallbackResolution; + let finalResolution; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== "undefined" && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + if (!fallbackResolution) { + throw new GeneralError("Fallback provider not found in resolution results"); + } + if (!finalResolution) { + throw new GeneralError("Final resolution not found in resolution results"); + } + if (mismatch) { + (_a = this.onMismatch) == null ? void 0 : _a.call(this, resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider + }; + } + return this.resolutionToFinalResult(finalResolution); + } +}; + +// src/events/events.ts +var ServerProviderEvents = /* @__PURE__ */ ((ServerProviderEvents2) => { + ServerProviderEvents2["Ready"] = "PROVIDER_READY"; + ServerProviderEvents2["Error"] = "PROVIDER_ERROR"; + ServerProviderEvents2["ConfigurationChanged"] = "PROVIDER_CONFIGURATION_CHANGED"; + ServerProviderEvents2["Stale"] = "PROVIDER_STALE"; + return ServerProviderEvents2; +})(ServerProviderEvents || {}); +var ClientProviderEvents = /* @__PURE__ */ ((ClientProviderEvents2) => { + ClientProviderEvents2["Ready"] = "PROVIDER_READY"; + ClientProviderEvents2["Error"] = "PROVIDER_ERROR"; + ClientProviderEvents2["ConfigurationChanged"] = "PROVIDER_CONFIGURATION_CHANGED"; + ClientProviderEvents2["ContextChanged"] = "PROVIDER_CONTEXT_CHANGED"; + ClientProviderEvents2["Reconciling"] = "PROVIDER_RECONCILING"; + ClientProviderEvents2["Stale"] = "PROVIDER_STALE"; + return ClientProviderEvents2; +})(ClientProviderEvents || {}); + +// src/events/event-utils.ts +var eventStatusMap = { + ["READY" /* READY */]: "PROVIDER_READY" /* Ready */, + ["ERROR" /* ERROR */]: "PROVIDER_ERROR" /* Error */, + ["FATAL" /* FATAL */]: "PROVIDER_ERROR" /* Error */, + ["STALE" /* STALE */]: "PROVIDER_STALE" /* Stale */, + ["RECONCILING" /* RECONCILING */]: "PROVIDER_RECONCILING" /* Reconciling */, + ["NOT_READY" /* NOT_READY */]: void 0 +}; +var statusMatchesEvent = (event, status) => { + return !status && event === "PROVIDER_READY" /* Ready */ || eventStatusMap[status] === event; +}; + +// src/logger/default-logger.ts +var DefaultLogger = class { + error(...args) { + console.error(...args); + } + warn(...args) { + console.warn(...args); + } + info() { + } + debug() { + } +}; + +// src/logger/safe-logger.ts +var LOG_LEVELS = ["error", "warn", "info", "debug"]; +var SafeLogger = class { + constructor(logger) { + this.fallbackLogger = new DefaultLogger(); + try { + for (const level of LOG_LEVELS) { + if (!logger[level] || typeof logger[level] !== "function") { + throw new Error(`The provided logger is missing the ${level} method.`); + } + } + this.logger = logger; + } catch (err) { + console.error(err); + console.error("Falling back to the default logger."); + this.logger = this.fallbackLogger; + } + } + error(...args) { + this.log("error", ...args); + } + warn(...args) { + this.log("warn", ...args); + } + info(...args) { + this.log("info", ...args); + } + debug(...args) { + this.log("debug", ...args); + } + log(level, ...args) { + try { + this.logger[level](...args); + } catch (error) { + this.fallbackLogger[level](...args); + } + } +}; + +// src/events/generic-event-emitter.ts +var GenericEventEmitter = class { + constructor(globalLogger) { + this.globalLogger = globalLogger; + this._handlers = { + ["PROVIDER_CONFIGURATION_CHANGED" /* ConfigurationChanged */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_CONTEXT_CHANGED" /* ContextChanged */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_READY" /* Ready */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_ERROR" /* Error */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_STALE" /* Stale */]: /* @__PURE__ */ new WeakMap(), + ["PROVIDER_RECONCILING" /* Reconciling */]: /* @__PURE__ */ new WeakMap() + }; + } + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType, context) { + this.eventEmitter.emit(eventType, context); + } + addHandler(eventType, handler) { + const asyncHandler = (details) => __async(this, null, function* () { + var _a; + try { + yield handler(details); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + }); + const existingAsyncHandlers = this._handlers[eventType].get(handler); + this._handlers[eventType].set(handler, [...existingAsyncHandlers || [], asyncHandler]); + this.eventEmitter.on(eventType, asyncHandler); + } + removeHandler(eventType, handler) { + const existingAsyncHandlers = this._handlers[eventType].get(handler); + if (existingAsyncHandlers) { + const removedAsyncHandler = existingAsyncHandlers.pop(); + if (removedAsyncHandler) { + this.eventEmitter.removeListener(eventType, removedAsyncHandler); + } + } + } + removeAllHandlers(eventType) { + if (eventType) { + this.eventEmitter.removeAllListeners(eventType); + } else { + this.eventEmitter.removeAllListeners(); + } + } + getHandlers(eventType) { + return this.eventEmitter.listeners(eventType); + } + setLogger(logger) { + this._eventLogger = new SafeLogger(logger); + return this; + } + get _logger() { + var _a, _b; + return (_b = this._eventLogger) != null ? _b : (_a = this.globalLogger) == null ? void 0 : _a.call(this); + } +}; + +// src/telemetry/attributes.ts +var TelemetryAttribute = { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + KEY: "feature_flag.key", + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + ERROR_CODE: "error.type", + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + ERROR_MESSAGE: "error.message", + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + VARIANT: "feature_flag.result.variant", + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + VALUE: "feature_flag.result.value", + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + CONTEXT_ID: "feature_flag.context.id", + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + REASON: "feature_flag.result.reason", + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + PROVIDER: "feature_flag.provider.name", + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + FLAG_SET_ID: "feature_flag.set.id", + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + VERSION: "feature_flag.version" +}; + +// src/telemetry/flag-metadata.ts +var TelemetryFlagMetadata = { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + CONTEXT_ID: "contextId", + /** + * A logical identifier for the flag set. + */ + FLAG_SET_ID: "flagSetId", + /** + * A version string (format unspecified) for the flag or flag set. + */ + VERSION: "version" +}; + +// src/telemetry/evaluation-event.ts +var FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"; +function createEvaluationEvent(hookContext, evaluationDetails) { + var _a, _b, _c; + const attributes = { + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.REASON]: ((_a = evaluationDetails.reason) != null ? _a : StandardResolutionReasons.UNKNOWN).toLowerCase() + }; + if (evaluationDetails.variant) { + attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant; + } + if (evaluationDetails.value !== null) { + if (typeof evaluationDetails.value !== "object") { + attributes[TelemetryAttribute.VALUE] = evaluationDetails.value; + } else { + try { + attributes[TelemetryAttribute.VALUE] = JSON.stringify(evaluationDetails.value); + } catch (e) { + } + } + } + const contextId = (_b = evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID]) != null ? _b : hookContext.context.targetingKey; + if (contextId) { + attributes[TelemetryAttribute.CONTEXT_ID] = contextId; + } + const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID]; + if (setId) { + attributes[TelemetryAttribute.FLAG_SET_ID] = setId; + } + const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION]; + if (version) { + attributes[TelemetryAttribute.VERSION] = version; + } + if (evaluationDetails.reason === StandardResolutionReasons.ERROR) { + attributes[TelemetryAttribute.ERROR_CODE] = ((_c = evaluationDetails.errorCode) != null ? _c : "GENERAL" /* GENERAL */).toLowerCase(); + if (evaluationDetails.errorMessage) { + attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage; + } + } + return { + name: FLAG_EVALUATION_EVENT_NAME, + attributes + }; +} + +// src/type-guards.ts +function isString(value) { + return typeof value === "string"; +} +function stringOrUndefined(value) { + return isString(value) ? value : void 0; +} +function isObject(value) { + return typeof value === "object"; +} +function objectOrUndefined(value) { + return isObject(value) ? value : void 0; +} + +// src/filter.ts +function isDefined(input) { + return typeof input !== "undefined" && input !== null; +} + +// src/open-feature.ts +var ProviderWrapper = class { + constructor(_provider, _status, _statusEnumType) { + this._provider = _provider; + this._status = _status; + this._pendingContextChanges = 0; + var _a, _b, _c; + (_a = _provider.events) == null ? void 0 : _a.addHandler("PROVIDER_READY" /* Ready */, () => { + this._status = _statusEnumType.READY; + }); + (_b = _provider.events) == null ? void 0 : _b.addHandler("PROVIDER_STALE" /* Stale */, () => { + this._status = _statusEnumType.STALE; + }); + (_c = _provider.events) == null ? void 0 : _c.addHandler("PROVIDER_ERROR" /* Error */, (details) => { + if ((details == null ? void 0 : details.errorCode) === "PROVIDER_FATAL" /* PROVIDER_FATAL */) { + this._status = _statusEnumType.FATAL; + } else { + this._status = _statusEnumType.ERROR; + } + }); + } + get provider() { + return this._provider; + } + set provider(provider) { + this._provider = provider; + } + get status() { + return this._status; + } + set status(status) { + this._status = status; + } + get allContextChangesSettled() { + return this._pendingContextChanges === 0; + } + incrementPendingContextChanges() { + this._pendingContextChanges++; + } + decrementPendingContextChanges() { + this._pendingContextChanges--; + } +}; +var OpenFeatureCommonAPI = class { + constructor(category) { + this._hooks = []; + this._context = {}; + this._logger = new DefaultLogger(); + this._clientEventHandlers = /* @__PURE__ */ new Map(); + this._domainScopedContext = /* @__PURE__ */ new Map(); + this._clientEvents = /* @__PURE__ */ new Map(); + this._runsOn = category; + } + addHooks(...hooks) { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + getHooks() { + return this._hooks; + } + clearHooks() { + this._hooks = []; + return this; + } + setLogger(logger) { + this._logger = new SafeLogger(logger); + return this; + } + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata() { + return this.getProviderMetadata(); + } + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain) { + return this.getProviderForClient(domain).metadata; + } + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType, handler, options) { + [.../* @__PURE__ */ new Map([[void 0, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => { + var _a; + const domain = keyProviderTuple[0]; + const provider = keyProviderTuple[1].provider; + const status = keyProviderTuple[1].status; + const shouldRunNow = statusMatchesEvent(eventType, status); + if (shouldRunNow) { + try { + handler({ domain, providerName: provider.metadata.name }); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + } + }); + this._apiEmitter.addHandler(eventType, handler); + if ((options == null ? void 0 : options.signal) && typeof options.signal.addEventListener === "function") { + options.signal.addEventListener("abort", () => { + this.removeHandler(eventType, handler); + }); + } + } + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType, handler) { + this._apiEmitter.removeHandler(eventType, handler); + } + /** + * Removes all event handlers. + */ + clearHandlers() { + this._apiEmitter.removeAllHandlers(); + } + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType) { + return this._apiEmitter.getHandlers(eventType); + } + setAwaitableProvider(domainOrProvider, providerOrUndefined) { + var _a, _b, _c, _d, _e, _f, _g, _h; + const domain = stringOrUndefined(domainOrProvider); + const provider = (_a = objectOrUndefined(domainOrProvider)) != null ? _a : objectOrUndefined(providerOrUndefined); + if (!provider) { + this._logger.debug("No provider defined, ignoring setProvider call"); + return; + } + const oldProvider = this.getProviderForClient(domain); + const providerName = provider.metadata.name; + if (oldProvider === provider) { + this._logger.debug("Provider is already set, ignoring setProvider call"); + return; + } + if (!provider.runsOn) { + this._logger.debug(`Provider '${provider.metadata.name}' has not defined its intended use.`); + } else if (provider.runsOn !== this._runsOn) { + throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`); + } + const emitters = this.getAssociatedEventEmitters(domain); + let initializationPromise = void 0; + const wrappedProvider = new ProviderWrapper( + provider, + this._statusEnumType.NOT_READY, + this._statusEnumType + ); + if (typeof provider.initialize === "function" && !this.allProviders.includes(provider)) { + initializationPromise = (_e = (_d = (_c = provider.initialize) == null ? void 0 : _c.call(provider, domain ? (_b = this._domainScopedContext.get(domain)) != null ? _b : this._context : this._context)) == null ? void 0 : _d.then(() => { + var _a2; + wrappedProvider.status = this._statusEnumType.READY; + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + }); + (_a2 = this._apiEmitter) == null ? void 0 : _a2.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + })) == null ? void 0 : _e.catch((error) => { + var _a2; + if ((error == null ? void 0 : error.code) === "PROVIDER_FATAL" /* PROVIDER_FATAL */) { + wrappedProvider.status = this._statusEnumType.FATAL; + } else { + wrappedProvider.status = this._statusEnumType.ERROR; + } + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_ERROR" /* Error */, { + clientName: domain, + domain, + providerName, + message: error == null ? void 0 : error.message + }); + }); + (_a2 = this._apiEmitter) == null ? void 0 : _a2.emit("PROVIDER_ERROR" /* Error */, { + clientName: domain, + domain, + providerName, + message: error == null ? void 0 : error.message + }); + throw error; + }); + } else { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter == null ? void 0 : emitter.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + }); + (_f = this._apiEmitter) == null ? void 0 : _f.emit("PROVIDER_READY" /* Ready */, { clientName: domain, domain, providerName }); + } + if (domain) { + this._domainScopedProviders.set(domain, wrappedProvider); + } else { + this._defaultProvider = wrappedProvider; + } + this.transferListeners(oldProvider, provider, domain, emitters); + if (!this.allProviders.includes(oldProvider)) { + (_h = (_g = oldProvider == null ? void 0 : oldProvider.onClose) == null ? void 0 : _g.call(oldProvider)) == null ? void 0 : _h.catch((err) => { + this._logger.error(`error closing provider: ${err == null ? void 0 : err.message}, ${err == null ? void 0 : err.stack}`); + }); + } + return initializationPromise; + } + getProviderForClient(domain) { + var _a, _b; + if (!domain) { + return this._defaultProvider.provider; + } + return (_b = (_a = this._domainScopedProviders.get(domain)) == null ? void 0 : _a.provider) != null ? _b : this._defaultProvider.provider; + } + buildAndCacheEventEmitterForClient(domain) { + const emitter = this._clientEvents.get(domain); + if (emitter) { + return emitter; + } + const newEmitter = this._createEventEmitter(); + this._clientEvents.set(domain, newEmitter); + const clientProvider = this.getProviderForClient(domain); + Object.values(ClientProviderEvents).forEach( + (eventType) => { + var _a; + return (_a = clientProvider.events) == null ? void 0 : _a.addHandler(eventType, (details) => __async(this, null, function* () { + newEmitter.emit(eventType, __spreadProps(__spreadValues({}, details), { + clientName: domain, + domain, + providerName: clientProvider.metadata.name + })); + })); + } + ); + return newEmitter; + } + getUnboundEmitters() { + const domainScopedProviders = [...this._domainScopedProviders.keys()]; + const eventEmitterNames = [...this._clientEvents.keys()].filter(isDefined); + const unboundEmitterNames = eventEmitterNames.filter((name) => !domainScopedProviders.includes(name)); + return [ + // all unbound, named emitters + ...unboundEmitterNames.map((name) => this._clientEvents.get(name)), + // the default emitter + this._clientEvents.get(void 0) + ].filter(isDefined); + } + getAssociatedEventEmitters(domain) { + return domain ? [this.buildAndCacheEventEmitterForClient(domain)] : this.getUnboundEmitters(); + } + transferListeners(oldProvider, newProvider, domain, emitters) { + var _a; + (_a = this._clientEventHandlers.get(domain)) == null ? void 0 : _a.forEach((eventHandler) => { + var _a2; + return (_a2 = oldProvider.events) == null ? void 0 : _a2.removeHandler(...eventHandler); + }); + const newClientHandlers = Object.values(ClientProviderEvents).map((eventType) => { + const handler = (details) => __async(this, null, function* () { + emitters.forEach((emitter) => { + emitter == null ? void 0 : emitter.emit(eventType, __spreadProps(__spreadValues({}, details), { clientName: domain, domain, providerName: newProvider.metadata.name })); + }); + this._apiEmitter.emit(eventType, __spreadProps(__spreadValues({}, details), { + clientName: domain, + domain, + providerName: newProvider.metadata.name + })); + }); + return [eventType, handler]; + }); + this._clientEventHandlers.set(domain, newClientHandlers); + newClientHandlers.forEach((eventHandler) => { + var _a2; + return (_a2 = newProvider.events) == null ? void 0 : _a2.addHandler(...eventHandler); + }); + } + close() { + return __async(this, null, function* () { + var _a, _b; + try { + yield (_b = (_a = this == null ? void 0 : this._defaultProvider.provider) == null ? void 0 : _a.onClose) == null ? void 0 : _b.call(_a); + } catch (err) { + this.handleShutdownError(this._defaultProvider.provider, err); + } + const wrappers = Array.from(this._domainScopedProviders); + yield Promise.all( + wrappers.map((_0) => __async(this, [_0], function* ([, wrapper]) { + var _a2, _b2; + try { + yield (_b2 = wrapper == null ? void 0 : (_a2 = wrapper.provider).onClose) == null ? void 0 : _b2.call(_a2); + } catch (err) { + this.handleShutdownError(wrapper == null ? void 0 : wrapper.provider, err); + } + })) + ); + }); + } + clearProvidersAndSetDefault(defaultProvider) { + return __async(this, null, function* () { + try { + yield this.close(); + } catch (err) { + this._logger.error("Unable to cleanly close providers. Resetting to the default configuration."); + } finally { + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType + ); + } + }); + } + get allProviders() { + return [ + ...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider), + this._defaultProvider.provider + ]; + } + handleShutdownError(provider, err) { + this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`); + this._logger.error(err == null ? void 0 : err.stack); + } +}; +export { + AggregateError, + ClientProviderEvents as AllProviderEvents, + ClientProviderStatus as AllProviderStatus, + ComparisonStrategy as BaseComparisonStrategy, + BaseEvaluationStrategy, + FirstMatchStrategy as BaseFirstMatchStrategy, + FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy, + ClientProviderEvents, + ClientProviderStatus, + DefaultLogger, + ErrorCode, + ErrorWithCode, + FlagNotFoundError, + GeneralError, + GenericEventEmitter, + InvalidContextError, + LOG_LEVELS, + MapHookData, + OpenFeatureCommonAPI, + OpenFeatureError, + ParseError, + ProviderFatalError, + ProviderNotReadyError, + ProviderWrapper, + SafeLogger, + ServerProviderEvents, + ServerProviderStatus, + StandardResolutionReasons, + StatusTracker, + TargetingKeyMissingError, + TelemetryAttribute, + TelemetryFlagMetadata, + TypeMismatchError, + constructAggregateError, + createEvaluationEvent, + instantiateErrorByErrorCode, + isObject, + isString, + objectOrUndefined, + statusMatchesEvent, + stringOrUndefined, + throwAggregateErrorFromPromiseResults +}; +//# sourceMappingURL=index.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts new file mode 100644 index 0000000000..e578317f55 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/dist/types.d.ts @@ -0,0 +1,1076 @@ +type Metadata = Record; + +/** + * Defines where the library is intended to be run. + */ +type Paradigm = 'server' | 'client'; + +type PrimitiveValue = null | boolean | string | number; +type JsonObject = { + [key: string]: JsonValue; +}; +type JsonArray = JsonValue[]; +/** + * Represents a JSON node value. + */ +type JsonValue = PrimitiveValue | JsonObject | JsonArray; + +type EvaluationContextValue = PrimitiveValue | Date | { + [key: string]: EvaluationContextValue; +} | EvaluationContextValue[]; +/** + * A container for arbitrary contextual data that can be used as a basis for dynamic evaluation + */ +type EvaluationContext = { + /** + * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. + * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. + * Such providers may behave unpredictably if a targeting key is not specified at flag resolution. + */ + targetingKey?: string; +} & Record; +interface ManageContext { + /** + * Access the evaluation context set on the receiver. + * @returns {EvaluationContext} Evaluation context + */ + getContext(): EvaluationContext; + /** + * Sets evaluation context that will be used during flag evaluations + * on this receiver. + * @template T The type of the receiver + * @param {EvaluationContext} context Evaluation context + * @returns {T} The receiver (this object) + */ + setContext(context: EvaluationContext): T; +} + +type FlagValueType = 'boolean' | 'string' | 'number' | 'object'; +/** + * Represents a JSON node value. + */ +type FlagValue = boolean | string | number | JsonValue; +type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); +/** + * A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + * + * This structure is populated by a provider for use by an Application Author (via the Evaluation API) or an Application Integrator (via hooks). + */ +type FlagMetadata = Record; +type ResolutionDetails = { + value: U; + variant?: string; + flagMetadata?: FlagMetadata; + reason?: ResolutionReason; + errorCode?: ErrorCode; + errorMessage?: string; +}; +type EvaluationDetails = { + flagKey: string; + flagMetadata: Readonly; +} & ResolutionDetails; +declare const StandardResolutionReasons: { + /** + * The resolved value is static (no dynamic evaluation). + */ + readonly STATIC: "STATIC"; + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + readonly DEFAULT: "DEFAULT"; + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + readonly TARGETING_MATCH: "TARGETING_MATCH"; + /** + * The resolved value was the result of pseudorandom assignment. + */ + readonly SPLIT: "SPLIT"; + /** + * The resolved value was retrieved from cache. + */ + readonly CACHED: "CACHED"; + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + readonly DISABLED: "DISABLED"; + /** + * The reason for the resolved value could not be determined. + */ + readonly UNKNOWN: "UNKNOWN"; + /** + * The resolved value is non-authoritative or possibly out of date. + */ + readonly STALE: "STALE"; + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + readonly ERROR: "ERROR"; +}; +declare enum ErrorCode { + /** + * The value was resolved before the provider was ready. + */ + PROVIDER_NOT_READY = "PROVIDER_NOT_READY", + /** + * The provider has entered an irrecoverable error state. + */ + PROVIDER_FATAL = "PROVIDER_FATAL", + /** + * The flag could not be found. + */ + FLAG_NOT_FOUND = "FLAG_NOT_FOUND", + /** + * An error was encountered parsing data, such as a flag configuration. + */ + PARSE_ERROR = "PARSE_ERROR", + /** + * The type of the flag value does not match the expected type. + */ + TYPE_MISMATCH = "TYPE_MISMATCH", + /** + * The provider requires a targeting key and one was not provided in the evaluation context. + */ + TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING", + /** + * The evaluation context does not meet provider requirements. + */ + INVALID_CONTEXT = "INVALID_CONTEXT", + /** + * An error with an unspecified code. + */ + GENERAL = "GENERAL" +} + +interface Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; +} +interface ManageLogger { + /** + * Sets a logger on this receiver. This logger supersedes to the global logger + * and is passed to various components in the SDK. + * The logger configured on the global API object will be used for all evaluations, + * unless overridden in a particular client. + * @template T The type of the receiver + * @param {Logger} logger The logger to be used + * @returns {T} The receiver (this object) + */ + setLogger(logger: Logger): T; +} + +declare class DefaultLogger implements Logger { + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(): void; + debug(): void; +} + +declare const LOG_LEVELS: Array; +declare class SafeLogger implements Logger { + private readonly logger; + private readonly fallbackLogger; + constructor(logger: Logger); + error(...args: unknown[]): void; + warn(...args: unknown[]): void; + info(...args: unknown[]): void; + debug(...args: unknown[]): void; + private log; +} + +/** + * An enumeration of possible events for server-sdk providers. + */ +declare enum ServerProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = "PROVIDER_READY", + /** + * The provider is in an error state. + */ + Error = "PROVIDER_ERROR", + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = "PROVIDER_CONFIGURATION_CHANGED", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = "PROVIDER_STALE" +} +/** + * An enumeration of possible events for web-sdk providers. + */ +declare enum ClientProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = "PROVIDER_READY", + /** + * The provider is in an error state. + */ + Error = "PROVIDER_ERROR", + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = "PROVIDER_CONFIGURATION_CHANGED", + /** + * The context associated with the provider has changed, and the provider has reconciled it's associated state. + */ + ContextChanged = "PROVIDER_CONTEXT_CHANGED", + /** + * The context associated with the provider has changed, and the provider has not yet reconciled its associated state. + */ + Reconciling = "PROVIDER_RECONCILING", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = "PROVIDER_STALE" +} + +/** + * A type representing any possible ProviderEvent (server or client side). + * In most cases, you probably want to import `ProviderEvents` from the respective SDK. + */ +type AnyProviderEvent = ServerProviderEvents | ClientProviderEvents; + +type EventMetadata = { + [key: string]: string | boolean | number; +}; +type CommonEventDetails = { + readonly providerName: string; + /** + * @deprecated alias of "domain", use domain instead + */ + readonly clientName?: string; + readonly domain?: string; +}; +type CommonEventProps = { + readonly message?: string; + readonly metadata?: EventMetadata; +}; +type ReadyEvent = CommonEventProps; +type ErrorEvent = CommonEventProps; +type StaleEvent = CommonEventProps; +type ReconcilingEvent = CommonEventProps & { + readonly errorCode: ErrorCode; +}; +type ConfigChangeEvent = CommonEventProps & { + readonly flagsChanged?: string[]; +}; +type ServerEventMap = { + [ServerProviderEvents.Ready]: ReadyEvent; + [ServerProviderEvents.Error]: ErrorEvent; + [ServerProviderEvents.Stale]: StaleEvent; + [ServerProviderEvents.ConfigurationChanged]: ConfigChangeEvent; +}; +type ClientEventMap = { + [ClientProviderEvents.Ready]: ReadyEvent; + [ClientProviderEvents.Error]: ErrorEvent; + [ClientProviderEvents.Stale]: StaleEvent; + [ClientProviderEvents.ConfigurationChanged]: ConfigChangeEvent; + [ClientProviderEvents.Reconciling]: CommonEventProps; + [ClientProviderEvents.ContextChanged]: CommonEventProps; +}; +type ServerNotChangeEvents = ServerProviderEvents.Ready | ServerProviderEvents.Error | ServerProviderEvents.Stale; +type ClientNotChangeEvents = ClientProviderEvents.Ready | ClientProviderEvents.Error | ClientProviderEvents.Stale | ClientProviderEvents.ContextChanged | ClientProviderEvents.Reconciling; +type NotChangeEvents = ServerNotChangeEvents | ClientNotChangeEvents; +type EventContext = Record, T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents> = (T extends ClientProviderEvents ? ClientEventMap[T] : T extends ServerProviderEvents ? ServerEventMap[T] : never) & U; +type EventDetails = EventContext, T> & CommonEventDetails; +type EventHandler = (eventDetails?: EventDetails) => Promise | unknown; +type EventOptions = { + signal?: AbortSignal; +}; +interface Eventing { + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * @param eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T extends ClientProviderEvents ? ClientProviderEvents.ConfigurationChanged : ServerProviderEvents.ConfigurationChanged, handler: EventHandler, options?: EventOptions): void; + addHandler(eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents, handler: EventHandler, options?: EventOptions): void; + addHandler(eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents, handler: EventHandler, options?: EventOptions): void; + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; +} + +/** + * Event emitter to be optionally implemented by providers. + * Implemented by @see OpenFeatureEventEmitter. + */ +interface ProviderEventEmitter = Record> extends ManageLogger> { + emit(eventType: E, context?: EventContext): void; + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeAllHandlers(eventType?: AnyProviderEvent): void; + getHandlers(eventType: AnyProviderEvent): EventHandler[]; +} + +/** + * Returns true if the provider's status corresponds to the event. + * If the provider's status is not defined, it matches READY. + * @param {AnyProviderEvent} event event to match + * @param {ClientProviderStatus | ServerProviderStatus} status status of provider + * @returns {boolean} boolean indicating if the provider status corresponds to the event. + */ +declare const statusMatchesEvent: (event: T, status?: ClientProviderStatus | ServerProviderStatus) => boolean; + +/** + * The GenericEventEmitter should only be used within the SDK. It supports additional properties that can be included + * in the event details. + */ +declare abstract class GenericEventEmitter = Record> implements ProviderEventEmitter, ManageLogger> { + private readonly globalLogger?; + protected abstract readonly eventEmitter: PlatformEventEmitter; + private readonly _handlers; + private _eventLogger?; + constructor(globalLogger?: (() => Logger) | undefined); + emit(eventType: E, context?: EventContext): void; + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + removeAllHandlers(eventType?: AnyProviderEvent): void; + getHandlers(eventType: AnyProviderEvent): EventHandler[]; + setLogger(logger: Logger): this; + protected get _logger(): Logger | undefined; +} +/** + * This is an un-exported type that corresponds to NodeJS.EventEmitter. + * We can't use that type here, because this module is used in both the browser, and the server. + * In the server, node (or whatever server runtime) provides an implementation for this. + * In the browser, we bundle in the popular 'EventEmitter3' package, which is a polyfill of NodeJS.EventEmitter. + */ +interface PlatformEventEmitter { + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + on(eventName: string | symbol, listener: (...args: any[]) => void): this; + once(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + off(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeAllListeners(event?: string | symbol): this; + listeners(eventName: string | symbol): Function[]; + emit(eventName: string | symbol, ...args: any[]): boolean; + listenerCount(eventName: string | symbol, listener?: Function): number; + eventNames(): Array; +} + +type TrackingEventValue = PrimitiveValue | Date | { + [key: string]: TrackingEventValue; +} | TrackingEventValue[]; +/** + * A container for arbitrary data that can relevant to tracking events. + */ +type TrackingEventDetails = { + /** + * A numeric value associated with this event. + */ + value?: number; +} & Record; + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +declare enum ServerProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = "NOT_READY", + /** + * The provider is ready to resolve flags. + */ + READY = "READY", + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = "ERROR", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = "STALE", + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = "FATAL" +} +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +declare enum ClientProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = "NOT_READY", + /** + * The provider is ready to resolve flags. + */ + READY = "READY", + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = "ERROR", + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = "STALE", + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = "FATAL", + /** + * The provider is reconciling its state with a context change. + */ + RECONCILING = "RECONCILING" +} + +/** + * Static data about the provider. + */ +interface ProviderMetadata extends Readonly { + readonly name: string; +} +interface CommonProvider { + readonly metadata: ProviderMetadata; + /** + * Represents where the provider is intended to be run. If defined, + * the SDK will enforce that the defined paradigm at runtime. + */ + readonly runsOn?: Paradigm; + /** + * @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field. + * Returns a representation of the current readiness of the provider. + * + * _Providers which do not implement this method are assumed to be ready immediately._ + */ + readonly status?: S; + /** + * An event emitter for ProviderEvents. + * @see ProviderEvents + */ + events?: ProviderEventEmitter; + /** + * A function used to shut down the provider. + * Called when this provider is replaced with a new one, or when the OpenFeature is shut down. + */ + onClose?(): Promise; + /** + * A function used to setup the provider. + * Called by the SDK after the provider is set if the provider's status is NOT_READY. + * When the returned promise resolves, the SDK fires the ProviderEvents.Ready event. + * If the returned promise rejects, the SDK fires the ProviderEvents.Error event. + * Use this function to perform any context-dependent setup within the provider. + * @param context + */ + initialize?(context?: EvaluationContext): Promise; + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName + * @param context + * @param trackingEventDetails + */ + track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; +} + +type ProviderEntryInput = { + provider: TProvider; + name?: string; +}; +type RegisteredProvider = Required>; + +/** + * Error Options were added in ES2022. Manually adding the type so that an + * earlier target can be used. + */ +type ErrorOptions = { + cause?: unknown; +}; +declare abstract class OpenFeatureError extends Error { + abstract code: ErrorCode; + cause?: unknown; + constructor(message?: string, options?: ErrorOptions); +} + +declare class FlagNotFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class GeneralError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class InvalidContextError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ParseError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ProviderFatalError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class ProviderNotReadyError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class TargetingKeyMissingError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare class TypeMismatchError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions); +} + +declare const instantiateErrorByErrorCode: (errorCode: ErrorCode, message?: string) => OpenFeatureError; + +declare class ErrorWithCode extends OpenFeatureError { + code: ErrorCode; + constructor(code: ErrorCode, message: string); +} +declare class AggregateError extends GeneralError { + originalErrors: { + source: string; + error: unknown; + }[]; + constructor(message: string, originalErrors: { + source: string; + error: unknown; + }[]); +} +declare const constructAggregateError: (providerErrors: { + error: unknown; + providerName: string; +}[]) => AggregateError; +declare const throwAggregateErrorFromPromiseResults: (result: PromiseSettledResult[], providerEntries: Required>[]) => void; + +/** + * Interface for the provider events enum that StatusTracker expects. + * Must have at least Error, Stale, ConfigurationChanged, and Ready events. + * Reconciling is optional (web only). + */ +type ProviderEventsEnum = { + Error: T; + Stale: T; + ConfigurationChanged: T; + Ready: T; + Reconciling?: T; +}; +/** + * Interface for the provider status enum that StatusTracker expects. + */ +type ProviderStatusEnum = { + NOT_READY: T; + READY: T; + ERROR: T; + STALE: T; + FATAL: T; + RECONCILING?: T; +}; +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +declare class StatusTracker; +}> { + private events; + private statusEnum; + private eventEnum; + private readonly providerStatuses; + constructor(events: ProviderEventEmitter, statusEnum: ProviderStatusEnum, eventEnum: ProviderEventsEnum); + wrapEventHandler(providerEntry: RegisteredProvider): void; + providerStatus(name: string): TProviderStatus; + private getStatusFromProviderStatuses; + private changeProviderStatus; +} + +type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; +type BaseStrategyProviderContext = { + provider: TProvider; + providerName: string; + providerStatus: TProviderStatus; +}; +type BaseStrategyPerProviderContext = StrategyEvaluationContext & BaseStrategyProviderContext; +type BaseProviderResolutionResultBase = { + provider: TProvider; + providerName: string; +}; +type BaseProviderResolutionSuccessResult = BaseProviderResolutionResultBase & { + details: ResolutionDetails; +}; +type BaseProviderResolutionErrorResult<_TProviderStatus, TProvider> = BaseProviderResolutionResultBase & { + thrownError: unknown; +}; +type BaseProviderResolutionResult = BaseProviderResolutionSuccessResult | BaseProviderResolutionErrorResult; +type BaseFinalResult = { + details?: ResolutionDetails; + provider?: TProvider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; +/** + * Base evaluation strategy for multi-provider flag resolution. + * + * This class is intended to be extended by concrete strategies and is not + * directly usable on its own. Implementations must provide a + * `determineFinalResult` method that takes the per-provider results and + * determines the final flag resolution outcome. + * + * The base class also provides default implementations for + * `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` that can be + * used as-is or overridden by subclasses as needed. + */ +declare abstract class BaseEvaluationStrategy { + protected statusEnum: Record; + runMode: 'parallel' | 'sequential'; + constructor(statusEnum: Record); + shouldEvaluateThisProvider(strategyContext: BaseStrategyPerProviderContext, _evalContext?: EvaluationContext): boolean; + shouldEvaluateNextProvider(_strategyContext?: BaseStrategyPerProviderContext, _context?: EvaluationContext, _result?: BaseProviderResolutionResult): boolean; + shouldTrackWithThisProvider(strategyContext: BaseStrategyProviderContext, _context?: EvaluationContext, _trackingEventName?: string, _trackingEventDetails?: TrackingEventDetails): boolean; + abstract determineFinalResult(strategyContext: StrategyEvaluationContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; + protected hasError(resolution: BaseProviderResolutionResult): resolution is BaseProviderResolutionErrorResult | (BaseProviderResolutionSuccessResult & { + details: ResolutionDetails & { + errorCode: ErrorCode; + }; + }); + protected hasErrorWithCode(resolution: BaseProviderResolutionResult, code: ErrorCode): boolean; + protected collectProviderErrors(resolutions: BaseProviderResolutionResult[]): BaseFinalResult; + protected resolutionToFinalResult(resolution: BaseProviderResolutionSuccessResult): { + details: ResolutionDetails; + provider: TProvider; + providerName: string; + }; +} + +declare class FirstMatchStrategy extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, result: BaseProviderResolutionResult): boolean; + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +declare class FirstSuccessfulStrategy extends BaseEvaluationStrategy { + shouldEvaluateNextProvider(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, result: BaseProviderResolutionResult): boolean; + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +declare class ComparisonStrategy extends BaseEvaluationStrategy { + private fallbackProvider; + private onMismatch?; + runMode: "parallel"; + constructor(statusEnum: Record, fallbackProvider: TProvider, onMismatch?: ((resolutions: BaseProviderResolutionResult[]) => void) | undefined); + determineFinalResult(strategyContext: BaseStrategyPerProviderContext, context: EvaluationContext, resolutions: BaseProviderResolutionResult[]): BaseFinalResult; +} + +interface ClientMetadata { + /** + * @deprecated alias of "domain", use domain instead + */ + readonly name?: string; + readonly domain?: string; + readonly version?: string; + readonly providerMetadata: ProviderMetadata; +} + +/** + * A mutable data structure for hooks to maintain state across their lifecycle. + * Each hook instance gets its own isolated data store that persists for the + * duration of a single flag evaluation. + * @template TData - A record type that defines the shape of the stored data + */ +interface HookData> { + /** + * Sets a value in the hook data store. + * @param key The key to store the value under + * @param value The value to store + */ + set(key: K, value: TData[K]): void; + set(key: string, value: unknown): void; + /** + * Gets a value from the hook data store. + * @param key The key to retrieve the value for + * @returns The stored value, or undefined if not found + */ + get(key: K): TData[K] | undefined; + get(key: string): unknown; + /** + * Checks if a key exists in the hook data store. + * @param key The key to check + * @returns True if the key exists, false otherwise + */ + has(key: K): boolean; + has(key: string): boolean; + /** + * Deletes a value from the hook data store. + * @param key The key to delete + * @returns True if the key was deleted, false if it didn't exist + */ + delete(key: K): boolean; + delete(key: string): boolean; + /** + * Clears all values from the hook data store. + */ + clear(): void; +} +/** + * Default implementation of HookData using a Map. + * @template TData - A record type that defines the shape of the stored data + */ +declare class MapHookData> implements HookData { + private readonly data; + set(key: K, value: TData[K]): void; + get(key: K): TData[K] | undefined; + has(key: K): boolean; + delete(key: K): boolean; + clear(): void; +} + +type HookHints = Readonly>; +interface HookContext> { + readonly flagKey: string; + readonly defaultValue: T; + readonly flagValueType: FlagValueType; + readonly context: Readonly; + readonly clientMetadata: ClientMetadata; + readonly providerMetadata: ProviderMetadata; + readonly logger: Logger; + readonly hookData: HookData; +} +interface BeforeHookContext> extends HookContext { + context: EvaluationContext; +} + +interface BaseHook, BeforeHookReturn = unknown, HooksReturn = unknown> { + /** + * Runs before flag values are resolved from the provider. + * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. + * @param hookContext + * @param hookHints + */ + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + /** + * Runs after flag values are successfully resolved from the provider. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + after?(hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): HooksReturn; + /** + * Runs in the event of an unhandled error or promise rejection during flag resolution, or any attached hooks. + * @param hookContext + * @param error + * @param hookHints + */ + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; + /** + * Runs after all other hook stages, regardless of success or error. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + finally?(hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): HooksReturn; +} + +interface EvaluationLifeCycle { + /** + * Adds hooks that will run during flag evaluations on this receiver. + * Hooks are executed in the order they were registered. Adding additional hooks + * will not remove existing hooks. + * Hooks registered on the global API object run with all evaluations. + * Hooks registered on the client run with all evaluations on that client. + * @template T The type of the receiver + * @param {BaseHook[]} hooks A list of hooks that should always run + * @returns {T} The receiver (this object) + */ + addHooks(...hooks: BaseHook[]): T; + /** + * Access all the hooks that are registered on this receiver. + * @returns {BaseHook[]} A list of the client hooks + */ + getHooks(): BaseHook[]; + /** + * Clears all the hooks that are registered on this receiver. + * @template T The type of the receiver + * @returns {T} The receiver (this object) + */ + clearHooks(): T; +} + +/** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ +declare const TelemetryAttribute: { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + readonly KEY: "feature_flag.key"; + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + readonly ERROR_CODE: "error.type"; + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + readonly ERROR_MESSAGE: "error.message"; + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + readonly VARIANT: "feature_flag.result.variant"; + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + readonly VALUE: "feature_flag.result.value"; + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + readonly CONTEXT_ID: "feature_flag.context.id"; + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + readonly REASON: "feature_flag.result.reason"; + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + readonly PROVIDER: "feature_flag.provider.name"; + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + readonly FLAG_SET_ID: "feature_flag.set.id"; + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + readonly VERSION: "feature_flag.version"; +}; + +/** + * Well-known flag metadata attributes for telemetry events. + * @see https://openfeature.dev/specification/appendix-d#flag-metadata + */ +declare const TelemetryFlagMetadata: { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + readonly CONTEXT_ID: "contextId"; + /** + * A logical identifier for the flag set. + */ + readonly FLAG_SET_ID: "flagSetId"; + /** + * A version string (format unspecified) for the flag or flag set. + */ + readonly VERSION: "version"; +}; + +/** + * Attribute types for OpenTelemetry. + * @see https://github.com/open-telemetry/opentelemetry-js/blob/fbbce6e1c0de86e4c504b5788d876fae4d3bc254/api/src/common/Attributes.ts#L35 + */ +declare type AttributeValue = string | number | boolean | string[] | number[] | boolean[]; +type EvaluationEvent = { + /** + * The name of the feature flag evaluation event. + */ + name: string; + /** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @experimental The attributes are subject to change. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ + attributes: Record; +}; +/** + * Returns an OpenTelemetry compliant event for flag evaluation. + * @param {HookContext} hookContext Contextual information about the flag evaluation + * @param {EvaluationDetails} evaluationDetails The details of the flag evaluation + * @returns {EvaluationEvent} An evaluation event object containing the event name and attributes + */ +declare function createEvaluationEvent(hookContext: Readonly>, evaluationDetails: EvaluationDetails): EvaluationEvent; + +/** + * Checks whether the parameter is a string. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is a string + */ +declare function isString(value: unknown): value is string; +/** + * Returns the parameter if it is a string, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {string|undefined} The parameter if it is a string, otherwise undefined + */ +declare function stringOrUndefined(value: unknown): string | undefined; +/** + * Checks whether the parameter is an object. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is an object + */ +declare function isObject(value: unknown): value is T; +/** + * Returns the parameter if it is an object, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {object|undefined} The parameter if it is an object, otherwise undefined + */ +declare function objectOrUndefined(value: unknown): T | undefined; + +type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; +/** + * A provider and its current status. + * For internal use only. + */ +declare class ProviderWrapper

, S extends AnyProviderStatus> { + private _provider; + private _status; + private _pendingContextChanges; + constructor(_provider: P, _status: S, _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus); + get provider(): P; + set provider(provider: P); + get status(): S; + set status(status: S); + get allContextChangesSettled(): boolean; + incrementPendingContextChanges(): void; + decrementPendingContextChanges(): void; +} +declare abstract class OpenFeatureCommonAPI = CommonProvider, H extends BaseHook = BaseHook> implements Eventing, EvaluationLifeCycle>, ManageLogger> { + protected abstract readonly _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus; + protected abstract _createEventEmitter(): GenericEventEmitter; + protected abstract _defaultProvider: ProviderWrapper; + protected abstract readonly _domainScopedProviders: Map>; + protected abstract readonly _apiEmitter: GenericEventEmitter; + protected _hooks: H[]; + protected _context: EvaluationContext; + protected _logger: Logger; + private readonly _clientEventHandlers; + protected _domainScopedContext: Map; + protected _clientEvents: Map>; + protected _runsOn: Paradigm; + constructor(category: Paradigm); + addHooks(...hooks: H[]): this; + getHooks(): H[]; + clearHooks(): this; + setLogger(logger: Logger): this; + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata(): ProviderMetadata; + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain?: string): ProviderMetadata; + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T, handler: EventHandler, options?: EventOptions): void; + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + /** + * Removes all event handlers. + */ + clearHandlers(): void; + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; + abstract setProviderAndWait(clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, contextOrUndefined?: EvaluationContext): Promise; + abstract setProvider(clientOrProvider?: string | P, providerContextOrUndefined?: P | EvaluationContext, contextOrUndefined?: EvaluationContext): this; + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void; + protected getProviderForClient(domain?: string): P; + protected buildAndCacheEventEmitterForClient(domain?: string): GenericEventEmitter; + private getUnboundEmitters; + protected getAssociatedEventEmitters(domain: string | undefined): GenericEventEmitter>[]; + private transferListeners; + close(): Promise; + protected clearProvidersAndSetDefault(defaultProvider: P): Promise; + private get allProviders(); + private handleShutdownError; +} + +export { AggregateError, ClientProviderEvents as AllProviderEvents, ClientProviderStatus as AllProviderStatus, ComparisonStrategy as BaseComparisonStrategy, BaseEvaluationStrategy, FirstMatchStrategy as BaseFirstMatchStrategy, FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy, ClientProviderEvents, ClientProviderStatus, DefaultLogger, ErrorCode, ErrorWithCode, FlagNotFoundError, GeneralError, GenericEventEmitter, InvalidContextError, LOG_LEVELS, MapHookData, OpenFeatureCommonAPI, OpenFeatureError, ParseError, ProviderFatalError, ProviderNotReadyError, ProviderWrapper, SafeLogger, ServerProviderEvents, ServerProviderStatus, StandardResolutionReasons, StatusTracker, TargetingKeyMissingError, TelemetryAttribute, TelemetryFlagMetadata, TypeMismatchError, constructAggregateError, createEvaluationEvent, instantiateErrorByErrorCode, isObject, isString, objectOrUndefined, statusMatchesEvent, stringOrUndefined, throwAggregateErrorFromPromiseResults }; +export type { AnyProviderEvent, AttributeValue, BaseFinalResult, BaseHook, BaseProviderResolutionErrorResult, BaseProviderResolutionResult, BaseProviderResolutionSuccessResult, BaseStrategyPerProviderContext, BaseStrategyProviderContext, BeforeHookContext, ClientMetadata, CommonEventDetails, CommonProvider, ConfigChangeEvent, ErrorEvent, EvaluationContext, EvaluationContextValue, EvaluationDetails, EvaluationLifeCycle, EventContext, EventDetails, EventHandler, EventMetadata, EventOptions, Eventing, FlagMetadata, FlagValue, FlagValueType, HookContext, HookData, HookHints, JsonArray, JsonObject, JsonValue, Logger, ManageContext, ManageLogger, Metadata, NotChangeEvents, Paradigm, PrimitiveValue, ProviderEntryInput, ProviderEventEmitter, ProviderMetadata, ReadyEvent, ReconcilingEvent, RegisteredProvider, ResolutionDetails, ResolutionReason, StaleEvent, StrategyEvaluationContext, TrackingEventDetails, TrackingEventValue }; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json new file mode 100644 index 0000000000..daf79712eb --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core/package.json @@ -0,0 +1,49 @@ +{ + "name": "@openfeature/core", + "version": "1.9.2", + "description": "Shared OpenFeature JS components (server and web)", + "main": "./dist/cjs/index.js", + "files": [ + "dist/" + ], + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "types": "./dist/types.d.ts", + "devDependencies": {}, + "scripts": { + "test": "jest --verbose", + "lint": "eslint ./", + "lint:fix": "eslint ./ --fix", + "clean": "shx rm -rf ./dist", + "type": "tsc --project ./tsconfig.json --declaration --emitDeclarationOnly", + "build:esm": "esbuild src/index.ts --bundle --external:events --sourcemap --target=es2015 --format=esm --outfile=./dist/esm/index.js --analyze", + "build:cjs": "esbuild src/index.ts --bundle --external:events --sourcemap --target=es2015 --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:rollup-types": "rollup -c ../../rollup.config.mjs", + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types", + "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", + "current-version": "echo $npm_package_version", + "prepack": "shx cp ./../../LICENSE ./LICENSE", + "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi", + "version": "echo $npm_package_version" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-feature/js-sdk.git" + }, + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-feature/js-sdk/issues" + }, + "homepage": "https://github.com/open-feature/js-sdk#readme", + "keywords": [ + "openfeature", + "feature", + "flags", + "toggles" + ] +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core new file mode 120000 index 0000000000..44065895c2 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/core @@ -0,0 +1 @@ +../../../@openfeature+core@1.9.2/node_modules/@openfeature/core \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js new file mode 100644 index 0000000000..1640ba6626 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/index.js @@ -0,0 +1,634 @@ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; + +// src/provider/provider.ts +import { + ServerProviderStatus +} from "@openfeature/core"; + +// src/provider/no-op-provider.ts +var REASON_NO_OP = "No-op"; +var NoopFeatureProvider = class { + constructor() { + this.metadata = { + name: "No-op Provider" + }; + } + resolveBooleanEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveStringEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveNumberEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + resolveObjectEvaluation(_, defaultValue) { + return this.noOp(defaultValue); + } + noOp(defaultValue) { + return Promise.resolve({ + value: defaultValue, + reason: REASON_NO_OP + }); + } +}; +var NOOP_PROVIDER = new NoopFeatureProvider(); + +// src/provider/in-memory-provider/in-memory-provider.ts +import { + FlagNotFoundError, + GeneralError, + OpenFeatureError as OpenFeatureError2, + StandardResolutionReasons, + TypeMismatchError +} from "@openfeature/core"; + +// src/provider/in-memory-provider/variant-not-found-error.ts +import { ErrorCode, OpenFeatureError } from "@openfeature/core"; +var VariantFoundError = class _VariantFoundError extends OpenFeatureError { + constructor(message) { + super(message); + Object.setPrototypeOf(this, _VariantFoundError.prototype); + this.name = "VariantFoundError"; + this.code = ErrorCode.GENERAL; + } +}; + +// src/provider/in-memory-provider/in-memory-provider.ts +var InMemoryProvider = class { + constructor(flagConfiguration = {}) { + this.events = new OpenFeatureEventEmitter(); + this.runsOn = "server"; + this.metadata = { + name: "in-memory" + }; + this._flagConfiguration = __spreadValues({}, flagConfiguration); + } + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration).filter(([key, value]) => this._flagConfiguration[key] !== value).map(([key]) => key); + this._flagConfiguration = __spreadValues({}, flagConfiguration); + this.events.emit(ServerProviderEvents.ConfigurationChanged, { flagsChanged }); + } + resolveBooleanEvaluation(flagKey, defaultValue, context, logger) { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + resolveNumberEvaluation(flagKey, defaultValue, context, logger) { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + resolveStringEvaluation(flagKey, defaultValue, context, logger) { + return __async(this, null, function* () { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + }); + } + resolveObjectEvaluation(flagKey, defaultValue, context, logger) { + return __async(this, null, function* () { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + }); + } + resolveFlagWithReason(flagKey, defaultValue, ctx, logger) { + return __async(this, null, function* () { + try { + const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger); + if (typeof (resolutionResult == null ? void 0 : resolutionResult.value) != typeof defaultValue) { + throw new TypeMismatchError(); + } + return resolutionResult; + } catch (error) { + if (!(error instanceof OpenFeatureError2)) { + throw new GeneralError((error == null ? void 0 : error.message) || "unknown error"); + } + throw error; + } + }); + } + lookupFlagValue(flagKey, defaultValue, ctx, logger) { + var _a; + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger == null ? void 0 : logger.debug(message); + throw new FlagNotFoundError(message); + } + const flagSpec = this._flagConfiguration[flagKey]; + if (flagSpec.disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + const isContextEval = ctx && (flagSpec == null ? void 0 : flagSpec.contextEvaluator); + const variant = isContextEval ? (_a = flagSpec.contextEvaluator) == null ? void 0 : _a.call(flagSpec, ctx) : flagSpec.defaultVariant; + const value = variant && (flagSpec == null ? void 0 : flagSpec.variants[variant]); + if (value === void 0) { + const message = `no value associated with variant ${variant}`; + logger == null ? void 0 : logger.error(message); + throw new VariantFoundError(message); + } + return __spreadProps(__spreadValues({ + value + }, variant && { variant }), { + reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC + }); + } +}; + +// src/open-feature.ts +import { + OpenFeatureCommonAPI, + ProviderWrapper, + objectOrUndefined, + stringOrUndefined +} from "@openfeature/core"; + +// src/client/internal/open-feature-client.ts +import { + ErrorCode as ErrorCode2, + ProviderFatalError, + ProviderNotReadyError, + SafeLogger, + StandardResolutionReasons as StandardResolutionReasons2, + instantiateErrorByErrorCode, + statusMatchesEvent +} from "@openfeature/core"; +var OpenFeatureClient = class { + constructor(providerAccessor, providerStatusAccessor, emitterAccessor, apiContextAccessor, apiHooksAccessor, transactionContextAccessor, globalLogger, options, context = {}) { + this.providerAccessor = providerAccessor; + this.providerStatusAccessor = providerStatusAccessor; + this.emitterAccessor = emitterAccessor; + this.apiContextAccessor = apiContextAccessor; + this.apiHooksAccessor = apiHooksAccessor; + this.transactionContextAccessor = transactionContextAccessor; + this.globalLogger = globalLogger; + this.options = options; + this._hooks = []; + this._context = context; + } + get metadata() { + var _a, _b; + return { + // Use domain if name is not provided + name: (_a = this.options.domain) != null ? _a : this.options.name, + domain: (_b = this.options.domain) != null ? _b : this.options.name, + version: this.options.version, + providerMetadata: this.providerAccessor().metadata + }; + } + get providerStatus() { + return this.providerStatusAccessor(); + } + addHandler(eventType, handler, options) { + var _a; + this.emitterAccessor().addHandler(eventType, handler); + const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus); + if (shouldRunNow) { + try { + handler({ + clientName: this.metadata.name, + domain: this.metadata.domain, + providerName: this._provider.metadata.name + }); + } catch (err) { + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + } + } + if ((options == null ? void 0 : options.signal) && typeof options.signal.addEventListener === "function") { + options.signal.addEventListener("abort", () => { + this.removeHandler(eventType, handler); + }); + } + } + removeHandler(eventType, handler) { + this.emitterAccessor().removeHandler(eventType, handler); + } + getHandlers(eventType) { + return this.emitterAccessor().getHandlers(eventType); + } + setLogger(logger) { + this._clientLogger = new SafeLogger(logger); + return this; + } + setContext(context) { + this._context = context; + return this; + } + getContext() { + return this._context; + } + addHooks(...hooks) { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + getHooks() { + return this._hooks; + } + clearHooks() { + this._hooks = []; + return this; + } + getBooleanValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getBooleanDetails(flagKey, defaultValue, context, options)).value; + }); + } + getBooleanDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + this._provider.resolveBooleanEvaluation, + defaultValue, + "boolean", + context, + options + ); + } + getStringValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getStringDetails(flagKey, defaultValue, context, options)).value; + }); + } + getStringDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + // this isolates providers from our restricted string generic argument. + this._provider.resolveStringEvaluation, + defaultValue, + "string", + context, + options + ); + } + getNumberValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getNumberDetails(flagKey, defaultValue, context, options)).value; + }); + } + getNumberDetails(flagKey, defaultValue, context, options) { + return this.evaluate( + flagKey, + // this isolates providers from our restricted number generic argument. + this._provider.resolveNumberEvaluation, + defaultValue, + "number", + context, + options + ); + } + getObjectValue(flagKey, defaultValue, context, options) { + return __async(this, null, function* () { + return (yield this.getObjectDetails(flagKey, defaultValue, context, options)).value; + }); + } + getObjectDetails(flagKey, defaultValue, context, options) { + return this.evaluate(flagKey, this._provider.resolveObjectEvaluation, defaultValue, "object", context, options); + } + track(occurrenceKey, context = {}, occurrenceDetails = {}) { + var _a, _b; + try { + this.shortCircuitIfNotReady(); + if (typeof this._provider.track === "function") { + const frozenContext = Object.freeze(this.mergeContexts(context)); + return (_b = (_a = this._provider).track) == null ? void 0 : _b.call(_a, occurrenceKey, frozenContext, occurrenceDetails); + } else { + this._logger.debug("Provider does not support the track function; will no-op."); + } + } catch (err) { + this._logger.debug("Error recording tracking event.", err); + } + } + evaluate(_0, _1, _2, _3) { + return __async(this, arguments, function* (flagKey, resolver, defaultValue, flagType, invocationContext = {}, options = {}) { + var _a; + const allHooks = [ + ...this.apiHooksAccessor(), + ...this.getHooks(), + ...options.hooks || [], + ...this._provider.hooks || [] + ]; + const allHooksReversed = [...allHooks].reverse(); + const mergedContext = this.mergeContexts(invocationContext); + const hookContext = { + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context: mergedContext, + logger: this._logger + }; + let evaluationDetails; + try { + const frozenContext = yield this.beforeHooks(allHooks, hookContext, options); + this.shortCircuitIfNotReady(); + const resolution = yield resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); + const resolutionDetails = __spreadProps(__spreadValues({}, resolution), { + flagMetadata: Object.freeze((_a = resolution.flagMetadata) != null ? _a : {}), + flagKey + }); + if (resolutionDetails.errorCode) { + const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); + yield this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); + } else { + yield this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + evaluationDetails = resolutionDetails; + } + } catch (err) { + yield this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); + } + yield this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + return evaluationDetails; + }); + } + beforeHooks(hooks, hookContext, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + Object.freeze(hookContext); + Object.assign(hookContext.context, __spreadValues(__spreadValues({}, hookContext.context), yield (_a = hook == null ? void 0 : hook.before) == null ? void 0 : _a.call(hook, hookContext, Object.freeze(options.hookHints)))); + } + return Object.freeze(hookContext.context); + }); + } + afterHooks(hooks, hookContext, evaluationDetails, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + yield (_a = hook == null ? void 0 : hook.after) == null ? void 0 : _a.call(hook, hookContext, evaluationDetails, options.hookHints); + } + }); + } + errorHooks(hooks, hookContext, err, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + try { + yield (_a = hook == null ? void 0 : hook.error) == null ? void 0 : _a.call(hook, hookContext, err, options.hookHints); + } catch (err2) { + this._logger.error(`Unhandled error during 'error' hook: ${err2}`); + if (err2 instanceof Error) { + this._logger.error(err2.stack); + } + this._logger.error(err2 == null ? void 0 : err2.stack); + } + } + }); + } + finallyHooks(hooks, hookContext, evaluationDetails, options) { + return __async(this, null, function* () { + var _a; + for (const hook of hooks) { + try { + yield (_a = hook == null ? void 0 : hook.finally) == null ? void 0 : _a.call(hook, hookContext, evaluationDetails, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error(err == null ? void 0 : err.stack); + } + } + }); + } + get _provider() { + return this.providerAccessor(); + } + get _providerStatus() { + return this.providerStatusAccessor(); + } + get _logger() { + return this._clientLogger || this.globalLogger(); + } + mergeContexts(invocationContext) { + return __spreadValues(__spreadValues(__spreadValues(__spreadValues({}, this.apiContextAccessor()), this.transactionContextAccessor()), this._context), invocationContext); + } + shortCircuitIfNotReady() { + if (this.providerStatus === ServerProviderStatus.NOT_READY) { + throw new ProviderNotReadyError("provider has not yet initialized"); + } else if (this.providerStatus === ServerProviderStatus.FATAL) { + throw new ProviderFatalError("provider is in an irrecoverable error state"); + } + } + getErrorEvaluationDetails(flagKey, defaultValue, err, flagMetadata = {}) { + const errorMessage = err == null ? void 0 : err.message; + const errorCode = (err == null ? void 0 : err.code) || ErrorCode2.GENERAL; + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons2.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey + }; + } +}; + +// src/events/open-feature-event-emitter.ts +import { GenericEventEmitter } from "@openfeature/core"; +import { EventEmitter } from "node:events"; +var OpenFeatureEventEmitter = class extends GenericEventEmitter { + constructor() { + super(); + this.eventEmitter = new EventEmitter({ captureRejections: true }); + this.eventEmitter.on("error", (err) => { + var _a; + (_a = this._logger) == null ? void 0 : _a.error("Error running event handler:", err); + }); + } +}; + +// src/events/events.ts +import { ServerProviderEvents } from "@openfeature/core"; + +// src/transaction-context/no-op-transaction-context-propagator.ts +var NoopTransactionContextPropagator = class { + getTransactionContext() { + return {}; + } + setTransactionContext(_, callback, ...args) { + callback(...args); + } +}; +var NOOP_TRANSACTION_CONTEXT_PROPAGATOR = new NoopTransactionContextPropagator(); + +// src/transaction-context/async-local-storage-transaction-context-propagator.ts +import { AsyncLocalStorage } from "async_hooks"; +var AsyncLocalStorageTransactionContextPropagator = class { + constructor() { + this.asyncLocalStorage = new AsyncLocalStorage(); + } + getTransactionContext() { + var _a; + return (_a = this.asyncLocalStorage.getStore()) != null ? _a : {}; + } + setTransactionContext(transactionContext, callback, ...args) { + this.asyncLocalStorage.run(transactionContext, callback, ...args); + } +}; + +// src/open-feature.ts +var GLOBAL_OPENFEATURE_API_KEY = Symbol.for("@openfeature/js-sdk/api"); +var _globalThis = globalThis; +var OpenFeatureAPI = class _OpenFeatureAPI extends OpenFeatureCommonAPI { + constructor() { + super("server"); + this._statusEnumType = ServerProviderStatus; + this._apiEmitter = new OpenFeatureEventEmitter(); + this._defaultProvider = new ProviderWrapper( + NOOP_PROVIDER, + ServerProviderStatus.NOT_READY, + this._statusEnumType + ); + this._domainScopedProviders = /* @__PURE__ */ new Map(); + this._createEventEmitter = () => new OpenFeatureEventEmitter(); + this._transactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + } + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance() { + const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + if (globalApi) { + return globalApi; + } + const instance = new _OpenFeatureAPI(); + _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + return instance; + } + getProviderStatus(domain) { + var _a, _b; + if (!domain) { + return this._defaultProvider.status; + } + return (_b = (_a = this._domainScopedProviders.get(domain)) == null ? void 0 : _a.status) != null ? _b : this._defaultProvider.status; + } + setProviderAndWait(domainOrProvider, providerOrUndefined) { + return __async(this, null, function* () { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain ? objectOrUndefined(providerOrUndefined) : objectOrUndefined(domainOrProvider); + yield this.setAwaitableProvider(domain, provider); + }); + } + setProvider(clientOrProvider, providerOrUndefined) { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain ? objectOrUndefined(providerOrUndefined) : objectOrUndefined(clientOrProvider); + const maybePromise = this.setAwaitableProvider(domain, provider); + Promise.resolve(maybePromise).catch((err) => { + this._logger.error("Error during provider initialization:", err); + }); + return this; + } + getProvider(domain) { + return this.getProviderForClient(domain); + } + setContext(context) { + this._context = context; + return this; + } + getContext() { + return this._context; + } + getClient(domainOrContext, versionOrContext, contextOrUndefined) { + var _a, _b; + const domain = stringOrUndefined(domainOrContext); + const version = stringOrUndefined(versionOrContext); + const context = (_b = (_a = objectOrUndefined(domainOrContext)) != null ? _a : objectOrUndefined(versionOrContext)) != null ? _b : objectOrUndefined(contextOrUndefined); + return new OpenFeatureClient( + () => this.getProviderForClient(domain), + () => this.getProviderStatus(domain), + () => this.buildAndCacheEventEmitterForClient(domain), + () => this.getContext(), + () => this.getHooks(), + () => this.getTransactionContext(), + () => this._logger, + { domain, version }, + context + ); + } + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders() { + return super.clearProvidersAndSetDefault(NOOP_PROVIDER); + } + setTransactionContextPropagator(transactionContextPropagator) { + const baseMessage = "Invalid TransactionContextPropagator, will not be set: "; + if (typeof (transactionContextPropagator == null ? void 0 : transactionContextPropagator.getTransactionContext) !== "function") { + this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); + } else if (typeof (transactionContextPropagator == null ? void 0 : transactionContextPropagator.setTransactionContext) !== "function") { + this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); + } else { + this._transactionContextPropagator = transactionContextPropagator; + } + return this; + } + setTransactionContext(transactionContext, callback, ...args) { + this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); + } + getTransactionContext() { + try { + return this._transactionContextPropagator.getTransactionContext(); + } catch (err) { + const error = err; + this._logger.error(`Error getting transaction context: ${error == null ? void 0 : error.message}, returning empty context.`); + this._logger.error(error == null ? void 0 : error.stack); + return {}; + } + } +}; +var OpenFeature = OpenFeatureAPI.getInstance(); + +// src/index.ts +export * from "@openfeature/core"; +export { + AsyncLocalStorageTransactionContextPropagator, + InMemoryProvider, + NOOP_PROVIDER, + NOOP_TRANSACTION_CONTEXT_PROPAGATOR, + OpenFeature, + OpenFeatureAPI, + OpenFeatureEventEmitter, + ServerProviderEvents as ProviderEvents, + ServerProviderStatus as ProviderStatus +}; +//# sourceMappingURL=index.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts new file mode 100644 index 0000000000..d1188cec44 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/dist/types.d.ts @@ -0,0 +1,420 @@ +import { BaseHook, FlagValue, EvaluationContext, HookHints, EvaluationDetails, JsonValue, CommonProvider, ServerProviderStatus, Logger, ResolutionDetails, GenericEventEmitter, ServerProviderEvents, TrackingEventDetails, EvaluationLifeCycle, ManageContext, ManageLogger, Eventing, ClientMetadata, OpenFeatureCommonAPI, ProviderWrapper } from '@openfeature/core'; +export * from '@openfeature/core'; +export { ServerProviderEvents as ProviderEvents, ServerProviderStatus as ProviderStatus } from '@openfeature/core'; +import { EventEmitter } from 'node:events'; + +type Hook = BaseHook | EvaluationContext | void, Promise | void>; + +interface FlagEvaluationOptions { + hooks?: Hook[]; + hookHints?: HookHints; +} +interface Features { + /** + * Performs a flag evaluation that returns a boolean. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getBooleanValue(flagKey: string, defaultValue: boolean, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getBooleanDetails(flagKey: string, defaultValue: boolean, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns a string. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getStringValue(flagKey: string, defaultValue: string, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getStringValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getStringDetails(flagKey: string, defaultValue: string, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getStringDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns a number. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getNumberValue(flagKey: string, defaultValue: number, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getNumberValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getNumberDetails(flagKey: string, defaultValue: number, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getNumberDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + /** + * Performs a flag evaluation that returns an object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getObjectValue(flagKey: string, defaultValue: JsonValue, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + getObjectValue(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise; + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getObjectDetails(flagKey: string, defaultValue: JsonValue, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; + getObjectDetails(flagKey: string, defaultValue: T, context?: EvaluationContext, options?: FlagEvaluationOptions): Promise>; +} + +/** + * Interface that providers must implement to resolve flag values for their particular + * backend or vendor. + * + * Implementation for resolving all the required flag types must be defined. + */ +interface Provider extends CommonProvider { + /** + * A provider hook exposes a mechanism for provider authors to register hooks + * to tap into various stages of the flag evaluation lifecycle. These hooks can + * be used to perform side effects and mutate the context for purposes of the + * provider. Provider hooks are not configured or controlled by the application author. + */ + readonly hooks?: Hook[]; + /** + * Resolve a boolean flag and its evaluation details. + */ + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve a string flag and its evaluation details. + */ + resolveStringEvaluation(flagKey: string, defaultValue: string, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve a numeric flag and its evaluation details. + */ + resolveNumberEvaluation(flagKey: string, defaultValue: number, context: EvaluationContext, logger: Logger): Promise>; + /** + * Resolve and parse an object flag and its evaluation details. + */ + resolveObjectEvaluation(flagKey: string, defaultValue: T, context: EvaluationContext, logger: Logger): Promise>; +} + +/** + * The No-op provider is set by default, and simply always returns the default value. + */ +declare class NoopFeatureProvider implements Provider { + readonly metadata: { + readonly name: "No-op Provider"; + }; + resolveBooleanEvaluation(_: string, defaultValue: boolean): Promise>; + resolveStringEvaluation(_: string, defaultValue: string): Promise>; + resolveNumberEvaluation(_: string, defaultValue: number): Promise>; + resolveObjectEvaluation(_: string, defaultValue: T): Promise>; + private noOp; +} +declare const NOOP_PROVIDER: NoopFeatureProvider; + +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ + +type Variants = Record; +/** + * A Feature Flag definition, containing it's specification + */ +type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; +type FlagConfiguration = Record; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +declare class InMemoryProvider implements Provider { + readonly events: OpenFeatureEventEmitter; + readonly runsOn = "server"; + readonly metadata: { + readonly name: "in-memory"; + }; + private _flagConfiguration; + constructor(flagConfiguration?: FlagConfiguration); + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration: FlagConfiguration): void; + resolveBooleanEvaluation(flagKey: string, defaultValue: boolean, context?: EvaluationContext, logger?: Logger): Promise>; + resolveNumberEvaluation(flagKey: string, defaultValue: number, context?: EvaluationContext, logger?: Logger): Promise>; + resolveStringEvaluation(flagKey: string, defaultValue: string, context?: EvaluationContext, logger?: Logger): Promise>; + resolveObjectEvaluation(flagKey: string, defaultValue: T, context?: EvaluationContext, logger?: Logger): Promise>; + private resolveFlagWithReason; + private lookupFlagValue; +} + +/** + * The OpenFeatureEventEmitter can be used by provider developers to emit + * events at various parts of the provider lifecycle. + * + * NOTE: Ready and error events are automatically emitted by the SDK based on + * the result of the initialize method. + */ +declare class OpenFeatureEventEmitter extends GenericEventEmitter { + protected readonly eventEmitter: EventEmitter<[never]>; + constructor(); +} + +interface Tracking { + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName an identifier for the event + * @param context the evaluation context + * @param trackingEventDetails the details of the tracking event + */ + track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void; +} + +interface Client extends EvaluationLifeCycle, Features, ManageContext, ManageLogger, Tracking, Eventing { + readonly metadata: ClientMetadata; + /** + * Returns the status of the associated provider. + */ + readonly providerStatus: ServerProviderStatus; +} + +/** + * Transaction context is a mechanism for adding transaction specific context that + * is merged with evaluation context prior to flag evaluation. Examples of potential + * transaction specific context include: a user id, user agent, or request path. + */ +type TransactionContext = EvaluationContext; +interface ManageTransactionContextPropagator extends TransactionContextPropagator { + /** + * Sets a transaction context propagator on this receiver. The transaction context + * propagator is responsible for persisting context for the duration of a single + * transaction. + * @template T The type of the receiver + * @param {TransactionContextPropagator} transactionContextPropagator The context propagator to be used + * @returns {T} The receiver (this object) + */ + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): T; +} +interface TransactionContextPropagator { + /** + * Returns the currently defined transaction context using the registered transaction + * context propagator. + * @returns {TransactionContext} The current transaction context + */ + getTransactionContext(): TransactionContext; + /** + * Sets the transaction context using the registered transaction context propagator. + * Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling + * {@link this#getTransactionContext}. + * + * The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available + * to {@link callback} via {@link this#getTransactionContext}. + * + * The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}. + * + * Example: + * + * ```js + * app.use((req: Request, res: Response, next: NextFunction) => { + * const ip = res.headers.get("X-Forwarded-For") + * OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => { + * // The transaction context is used in any flag evaluation throughout the whole call chain of next + * next(); + * }); + * }) + * + * ``` + * @template TArgs The optional args passed to the callback function + * @template R The return value of the callback + * @param {TransactionContext} transactionContext The transaction specific context + * @param {(...args: unknown[]) => R} callback Callback function to run + * @param {...unknown[]} args Optional arguments that are passed to the callback function + */ + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} + +declare class NoopTransactionContextPropagator implements TransactionContextPropagator { + getTransactionContext(): EvaluationContext; + setTransactionContext(_: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} +declare const NOOP_TRANSACTION_CONTEXT_PROPAGATOR: NoopTransactionContextPropagator; + +declare class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator { + private asyncLocalStorage; + getTransactionContext(): EvaluationContext; + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; +} + +declare class OpenFeatureAPI extends OpenFeatureCommonAPI implements ManageContext, ManageTransactionContextPropagator> { + protected _statusEnumType: typeof ServerProviderStatus; + protected _apiEmitter: OpenFeatureEventEmitter; + protected _defaultProvider: ProviderWrapper; + protected _domainScopedProviders: Map>; + protected _createEventEmitter: () => OpenFeatureEventEmitter; + private _transactionContextPropagator; + private constructor(); + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance(): OpenFeatureAPI; + private getProviderStatus; + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + /** + * Sets the default provider for flag evaluations. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(provider: Provider): this; + /** + * Sets the provider for flag evaluations of providers with the given name. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(domain: string, provider: Provider): this; + /** + * Get the default provider. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @returns {Provider} Default Provider + */ + getProvider(): Provider; + /** + * Get the provider bound to the specified domain. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {Provider} Domain-scoped provider + */ + getProvider(domain?: string): Provider; + setContext(context: EvaluationContext): this; + getContext(): EvaluationContext; + /** + * A factory function for creating new domainless OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {string} version The version of the client (only used for metadata) + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, version: string, context?: EvaluationContext): Client; + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders(): Promise; + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): OpenFeatureCommonAPI; + setTransactionContext(transactionContext: TransactionContext, callback: (...args: TArgs) => R, ...args: TArgs): void; + getTransactionContext(): TransactionContext; +} +/** + * A singleton instance of the OpenFeature API. + * @returns {OpenFeatureAPI} OpenFeature API + */ +declare const OpenFeature: OpenFeatureAPI; + +export { AsyncLocalStorageTransactionContextPropagator, type Client, type Features, type FlagEvaluationOptions, type Hook, InMemoryProvider, type ManageTransactionContextPropagator, NOOP_PROVIDER, NOOP_TRANSACTION_CONTEXT_PROPAGATOR, OpenFeature, OpenFeatureAPI, OpenFeatureEventEmitter, type Provider, type Tracking, type TransactionContext, type TransactionContextPropagator }; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json new file mode 100644 index 0000000000..8e039cfdc9 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk/package.json @@ -0,0 +1,56 @@ +{ + "name": "@openfeature/server-sdk", + "version": "1.18.0", + "description": "OpenFeature SDK for JavaScript", + "main": "./dist/cjs/index.js", + "files": [ + "dist/" + ], + "exports": { + "types": "./dist/types.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/cjs/index.js" + }, + "types": "./dist/types.d.ts", + "scripts": { + "test": "jest --verbose", + "lint": "eslint ./", + "lint:fix": "eslint ./ --fix", + "clean": "shx rm -rf ./dist", + "build:esm": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=esm --outfile=./dist/esm/index.js --analyze", + "build:cjs": "esbuild src/index.ts --bundle --external:@openfeature/core --sourcemap --target=es2015 --platform=node --format=cjs --outfile=./dist/cjs/index.js --analyze", + "build:rollup-types": "rollup -c ../../rollup.config.mjs", + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:rollup-types", + "postbuild": "shx cp ./../../package.esm.json ./dist/esm/package.json", + "current-version": "echo $npm_package_version", + "prepack": "shx cp ./../../LICENSE ./LICENSE", + "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/open-feature/js-sdk.git" + }, + "keywords": [ + "openfeature", + "feature", + "flags", + "toggles", + "node" + ], + "author": "", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/open-feature/js-sdk/issues" + }, + "homepage": "https://github.com/open-feature/js-sdk#readme", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@openfeature/core": "^1.7.0" + }, + "devDependencies": { + "@openfeature/core": "^1.7.0" + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE new file mode 100644 index 0000000000..84c52fe2cf --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js new file mode 100644 index 0000000000..b30a31a9a8 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.js @@ -0,0 +1,2 @@ +module.exports=function(n){return{all:n=n||new Map,on:function(e,t){var i=n.get(e);i?i.push(t):n.set(e,[t])},off:function(e,t){var i=n.get(e);i&&(t?i.splice(i.indexOf(t)>>>0,1):n.set(e,[]))},emit:function(e,t){var i=n.get(e);i&&i.slice().map(function(n){n(t)}),(i=n.get("*"))&&i.slice().map(function(n){n(e,t)})}}}; +//# sourceMappingURL=mitt.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs new file mode 100644 index 0000000000..5a2e73a666 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.mjs @@ -0,0 +1,2 @@ +export default function(n){return{all:n=n||new Map,on:function(t,e){var i=n.get(t);i?i.push(e):n.set(t,[e])},off:function(t,e){var i=n.get(t);i&&(e?i.splice(i.indexOf(e)>>>0,1):n.set(t,[]))},emit:function(t,e){var i=n.get(t);i&&i.slice().map(function(n){n(e)}),(i=n.get("*"))&&i.slice().map(function(n){n(t,e)})}}} +//# sourceMappingURL=mitt.mjs.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js new file mode 100644 index 0000000000..6820e65874 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/dist/mitt.umd.js @@ -0,0 +1,2 @@ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).mitt=n()}(this,function(){return function(e){return{all:e=e||new Map,on:function(n,t){var f=e.get(n);f?f.push(t):e.set(n,[t])},off:function(n,t){var f=e.get(n);f&&(t?f.splice(f.indexOf(t)>>>0,1):e.set(n,[]))},emit:function(n,t){var f=e.get(n);f&&f.slice().map(function(e){e(t)}),(f=e.get("*"))&&f.slice().map(function(e){e(n,t)})}}}}); +//# sourceMappingURL=mitt.umd.js.map diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts new file mode 100644 index 0000000000..10dc70d2c0 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/index.d.ts @@ -0,0 +1,21 @@ +export declare type EventType = string | symbol; +export declare type Handler = (event: T) => void; +export declare type WildcardHandler> = (type: keyof T, event: T[keyof T]) => void; +export declare type EventHandlerList = Array>; +export declare type WildCardEventHandlerList> = Array>; +export declare type EventHandlerMap> = Map | WildCardEventHandlerList>; +export interface Emitter> { + all: EventHandlerMap; + on(type: Key, handler: Handler): void; + on(type: '*', handler: WildcardHandler): void; + off(type: Key, handler?: Handler): void; + off(type: '*', handler: WildcardHandler): void; + emit(type: Key, event: Events[Key]): void; + emit(type: undefined extends Events[Key] ? Key : never): void; +} +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} + */ +export default function mitt>(all?: EventHandlerMap): Emitter; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json new file mode 100644 index 0000000000..e51397cb41 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/mitt@3.0.1/node_modules/mitt/package.json @@ -0,0 +1,85 @@ +{ + "name": "mitt", + "version": "3.0.1", + "description": "Tiny 200b functional Event Emitter / pubsub.", + "module": "dist/mitt.mjs", + "main": "dist/mitt.js", + "jsnext:main": "dist/mitt.mjs", + "umd:main": "dist/mitt.umd.js", + "source": "src/index.ts", + "typings": "index.d.ts", + "exports": { + "types": "./index.d.ts", + "module": "./dist/mitt.mjs", + "import": "./dist/mitt.mjs", + "require": "./dist/mitt.js", + "default": "./dist/mitt.mjs" + }, + "scripts": { + "test": "npm-run-all --silent typecheck lint mocha test-types", + "mocha": "mocha test", + "test-types": "tsc test/test-types-compilation.ts --noEmit --strict", + "lint": "eslint src test --ext ts --ext js", + "typecheck": "tsc --noEmit", + "bundle": "microbundle -f es,cjs,umd", + "build": "npm-run-all --silent clean -p bundle -s docs", + "clean": "rimraf dist", + "docs": "documentation readme src/index.ts --section API -q --parse-extension ts", + "release": "npm run -s build -s && npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" + }, + "repository": "developit/mitt", + "keywords": [ + "events", + "eventemitter", + "emitter", + "pubsub" + ], + "homepage": "https://github.com/developit/mitt", + "authors": [ + "Jason Miller " + ], + "license": "MIT", + "files": [ + "dist", + "index.d.ts" + ], + "mocha": { + "extension": [ + "ts" + ], + "require": [ + "ts-node/register", + "esm" + ], + "spec": [ + "test/*_test.ts" + ] + }, + "prettier": { + "singleQuote": true, + "trailingComma": "none" + }, + "devDependencies": { + "@types/chai": "^4.2.11", + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.4", + "@types/sinon-chai": "^3.2.4", + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "chai": "^4.2.0", + "documentation": "^14.0.2", + "eslint": "^7.32.0", + "eslint-config-developit": "^1.2.0", + "eslint-plugin-compat": "^4.1.4", + "esm": "^3.2.25", + "microbundle": "^0.12.3", + "mocha": "^8.0.1", + "npm-run-all": "^4.1.5", + "prettier": "^2.8.8", + "rimraf": "^3.0.2", + "sinon": "^9.0.2", + "sinon-chai": "^3.5.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE new file mode 100644 index 0000000000..983fbe8aec --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js new file mode 100644 index 0000000000..62e30de932 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist.es2015/index.js @@ -0,0 +1,415 @@ +/** + * Tokenize input string. + */ +function lexer(str) { + var tokens = []; + var i = 0; + while (i < str.length) { + var char = str[i]; + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + if (char === ":") { + var name = ""; + var j = i + 1; + while (j < str.length) { + var code = str.charCodeAt(j); + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95) { + name += str[j++]; + continue; + } + break; + } + if (!name) + throw new TypeError("Missing parameter name at ".concat(i)); + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + if (char === "(") { + var count = 1; + var pattern = ""; + var j = i + 1; + if (str[j] === "?") { + throw new TypeError("Pattern cannot start with \"?\" at ".concat(j)); + } + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } + else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError("Capturing groups are not allowed at ".concat(j)); + } + } + pattern += str[j++]; + } + if (count) + throw new TypeError("Unbalanced pattern at ".concat(i)); + if (!pattern) + throw new TypeError("Missing pattern at ".concat(i)); + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + tokens.push({ type: "END", index: i, value: "" }); + return tokens; +} +/** + * Parse a string for the raw tokens. + */ +export function parse(str, options) { + if (options === void 0) { options = {}; } + var tokens = lexer(str); + var _a = options.prefixes, prefixes = _a === void 0 ? "./" : _a, _b = options.delimiter, delimiter = _b === void 0 ? "/#?" : _b; + var result = []; + var key = 0; + var i = 0; + var path = ""; + var tryConsume = function (type) { + if (i < tokens.length && tokens[i].type === type) + return tokens[i++].value; + }; + var mustConsume = function (type) { + var value = tryConsume(type); + if (value !== undefined) + return value; + var _a = tokens[i], nextType = _a.type, index = _a.index; + throw new TypeError("Unexpected ".concat(nextType, " at ").concat(index, ", expected ").concat(type)); + }; + var consumeText = function () { + var result = ""; + var value; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + var isSafe = function (value) { + for (var _i = 0, delimiter_1 = delimiter; _i < delimiter_1.length; _i++) { + var char = delimiter_1[_i]; + if (value.indexOf(char) > -1) + return true; + } + return false; + }; + var safePattern = function (prefix) { + var prev = result[result.length - 1]; + var prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + if (prev && !prevText) { + throw new TypeError("Must have text between two parameters, missing text after \"".concat(prev.name, "\"")); + } + if (!prevText || isSafe(prevText)) + return "[^".concat(escapeString(delimiter), "]+?"); + return "(?:(?!".concat(escapeString(prevText), ")[^").concat(escapeString(delimiter), "])+?"); + }; + while (i < tokens.length) { + var char = tryConsume("CHAR"); + var name = tryConsume("NAME"); + var pattern = tryConsume("PATTERN"); + if (name || pattern) { + var prefix = char || ""; + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + if (path) { + result.push(path); + path = ""; + } + result.push({ + name: name || key++, + prefix: prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + var value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + if (path) { + result.push(path); + path = ""; + } + var open = tryConsume("OPEN"); + if (open) { + var prefix = consumeText(); + var name_1 = tryConsume("NAME") || ""; + var pattern_1 = tryConsume("PATTERN") || ""; + var suffix = consumeText(); + mustConsume("CLOSE"); + result.push({ + name: name_1 || (pattern_1 ? key++ : ""), + pattern: name_1 && !pattern_1 ? safePattern(prefix) : pattern_1, + prefix: prefix, + suffix: suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + mustConsume("END"); + } + return result; +} +/** + * Compile a string to a template function for the path. + */ +export function compile(str, options) { + return tokensToFunction(parse(str, options), options); +} +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction(tokens, options) { + if (options === void 0) { options = {}; } + var reFlags = flags(options); + var _a = options.encode, encode = _a === void 0 ? function (x) { return x; } : _a, _b = options.validate, validate = _b === void 0 ? true : _b; + // Compile all the tokens into regexps. + var matches = tokens.map(function (token) { + if (typeof token === "object") { + return new RegExp("^(?:".concat(token.pattern, ")$"), reFlags); + } + }); + return function (data) { + var path = ""; + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (typeof token === "string") { + path += token; + continue; + } + var value = data ? data[token.name] : undefined; + var optional = token.modifier === "?" || token.modifier === "*"; + var repeat = token.modifier === "*" || token.modifier === "+"; + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError("Expected \"".concat(token.name, "\" to not repeat, but got an array")); + } + if (value.length === 0) { + if (optional) + continue; + throw new TypeError("Expected \"".concat(token.name, "\" to not be empty")); + } + for (var j = 0; j < value.length; j++) { + var segment = encode(value[j], token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected all \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + var segment = encode(String(value), token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + continue; + } + if (optional) + continue; + var typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError("Expected \"".concat(token.name, "\" to be ").concat(typeOfMessage)); + } + return path; + }; +} +/** + * Create path match function from `path-to-regexp` spec. + */ +export function match(str, options) { + var keys = []; + var re = pathToRegexp(str, keys, options); + return regexpToFunction(re, keys, options); +} +/** + * Create a path match function from `path-to-regexp` output. + */ +export function regexpToFunction(re, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.decode, decode = _a === void 0 ? function (x) { return x; } : _a; + return function (pathname) { + var m = re.exec(pathname); + if (!m) + return false; + var path = m[0], index = m.index; + var params = Object.create(null); + var _loop_1 = function (i) { + if (m[i] === undefined) + return "continue"; + var key = keys[i - 1]; + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map(function (value) { + return decode(value, key); + }); + } + else { + params[key.name] = decode(m[i], key); + } + }; + for (var i = 1; i < m.length; i++) { + _loop_1(i); + } + return { path: path, index: index, params: params }; + }; +} +/** + * Escape a regular expression string. + */ +function escapeString(str) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} +/** + * Get the flags for a regexp from the options. + */ +function flags(options) { + return options && options.sensitive ? "" : "i"; +} +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path, keys) { + if (!keys) + return path; + var groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + var index = 0; + var execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + return path; +} +/** + * Transform an array into a regexp. + */ +function arrayToRegexp(paths, keys, options) { + var parts = paths.map(function (path) { return pathToRegexp(path, keys, options).source; }); + return new RegExp("(?:".concat(parts.join("|"), ")"), flags(options)); +} +/** + * Create a path regexp from string input. + */ +function stringToRegexp(path, keys, options) { + return tokensToRegexp(parse(path, options), keys, options); +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export function tokensToRegexp(tokens, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.strict, strict = _a === void 0 ? false : _a, _b = options.start, start = _b === void 0 ? true : _b, _c = options.end, end = _c === void 0 ? true : _c, _d = options.encode, encode = _d === void 0 ? function (x) { return x; } : _d, _e = options.delimiter, delimiter = _e === void 0 ? "/#?" : _e, _f = options.endsWith, endsWith = _f === void 0 ? "" : _f; + var endsWithRe = "[".concat(escapeString(endsWith), "]|$"); + var delimiterRe = "[".concat(escapeString(delimiter), "]"); + var route = start ? "^" : ""; + // Iterate over the tokens and create our regexp string. + for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) { + var token = tokens_1[_i]; + if (typeof token === "string") { + route += escapeString(encode(token)); + } + else { + var prefix = escapeString(encode(token.prefix)); + var suffix = escapeString(encode(token.suffix)); + if (token.pattern) { + if (keys) + keys.push(token); + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + var mod = token.modifier === "*" ? "?" : ""; + route += "(?:".concat(prefix, "((?:").concat(token.pattern, ")(?:").concat(suffix).concat(prefix, "(?:").concat(token.pattern, "))*)").concat(suffix, ")").concat(mod); + } + else { + route += "(?:".concat(prefix, "(").concat(token.pattern, ")").concat(suffix, ")").concat(token.modifier); + } + } + else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError("Can not repeat \"".concat(token.name, "\" without a prefix and suffix")); + } + route += "(".concat(token.pattern, ")").concat(token.modifier); + } + } + else { + route += "(?:".concat(prefix).concat(suffix, ")").concat(token.modifier); + } + } + } + if (end) { + if (!strict) + route += "".concat(delimiterRe, "?"); + route += !options.endsWith ? "$" : "(?=".concat(endsWithRe, ")"); + } + else { + var endToken = tokens[tokens.length - 1]; + var isEndDelimited = typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + if (!strict) { + route += "(?:".concat(delimiterRe, "(?=").concat(endsWithRe, "))?"); + } + if (!isEndDelimited) { + route += "(?=".concat(delimiterRe, "|").concat(endsWithRe, ")"); + } + } + return new RegExp(route, flags(options)); +} +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export function pathToRegexp(path, keys, options) { + if (path instanceof RegExp) + return regexpToRegexp(path, keys); + if (Array.isArray(path)) + return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts new file mode 100644 index 0000000000..6e5d250e3c --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.d.ts @@ -0,0 +1,127 @@ +export interface ParseOptions { + /** + * Set the default delimiter for repeat parameters. (default: `'/'`) + */ + delimiter?: string; + /** + * List of characters to automatically consider prefixes when parsing. + */ + prefixes?: string; +} +/** + * Parse a string for the raw tokens. + */ +export declare function parse(str: string, options?: ParseOptions): Token[]; +export interface TokensToFunctionOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * Function for encoding input strings for output. + */ + encode?: (value: string, token: Key) => string; + /** + * When `false` the function can produce an invalid (unmatched) path. (default: `true`) + */ + validate?: boolean; +} +/** + * Compile a string to a template function for the path. + */ +export declare function compile

(str: string, options?: ParseOptions & TokensToFunctionOptions): PathFunction

; +export type PathFunction

= (data?: P) => string; +/** + * Expose a method for transforming tokens into the path function. + */ +export declare function tokensToFunction

(tokens: Token[], options?: TokensToFunctionOptions): PathFunction

; +export interface RegexpToFunctionOptions { + /** + * Function for decoding strings for params. + */ + decode?: (value: string, token: Key) => string; +} +/** + * A match result contains data about the path match. + */ +export interface MatchResult

{ + path: string; + index: number; + params: P; +} +/** + * A match is either `false` (no match) or a match result. + */ +export type Match

= false | MatchResult

; +/** + * The match function takes a string and returns whether it matched the path. + */ +export type MatchFunction

= (path: string) => Match

; +/** + * Create path match function from `path-to-regexp` spec. + */ +export declare function match

(str: Path, options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions): MatchFunction

; +/** + * Create a path match function from `path-to-regexp` output. + */ +export declare function regexpToFunction

(re: RegExp, keys: Key[], options?: RegexpToFunctionOptions): MatchFunction

; +/** + * Metadata about a key. + */ +export interface Key { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +} +/** + * A token is a string (nothing special) or key metadata (capture group). + */ +export type Token = string | Key; +export interface TokensToRegexpOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) + */ + strict?: boolean; + /** + * When `true` the regexp will match to the end of the string. (default: `true`) + */ + end?: boolean; + /** + * When `true` the regexp will match from the beginning of the string. (default: `true`) + */ + start?: boolean; + /** + * Sets the final character for non-ending optimistic matches. (default: `/`) + */ + delimiter?: string; + /** + * List of characters that can also be "end" characters. + */ + endsWith?: string; + /** + * Encode path tokens for use in the `RegExp`. + */ + encode?: (value: string) => string; +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export declare function tokensToRegexp(tokens: Token[], keys?: Key[], options?: TokensToRegexpOptions): RegExp; +/** + * Supported `path-to-regexp` input types. + */ +export type Path = string | RegExp | Array; +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export declare function pathToRegexp(path: Path, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions): RegExp; diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js new file mode 100644 index 0000000000..7ee8a6aae6 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/dist/index.js @@ -0,0 +1,425 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pathToRegexp = exports.tokensToRegexp = exports.regexpToFunction = exports.match = exports.tokensToFunction = exports.compile = exports.parse = void 0; +/** + * Tokenize input string. + */ +function lexer(str) { + var tokens = []; + var i = 0; + while (i < str.length) { + var char = str[i]; + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + if (char === ":") { + var name = ""; + var j = i + 1; + while (j < str.length) { + var code = str.charCodeAt(j); + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95) { + name += str[j++]; + continue; + } + break; + } + if (!name) + throw new TypeError("Missing parameter name at ".concat(i)); + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + if (char === "(") { + var count = 1; + var pattern = ""; + var j = i + 1; + if (str[j] === "?") { + throw new TypeError("Pattern cannot start with \"?\" at ".concat(j)); + } + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } + else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError("Capturing groups are not allowed at ".concat(j)); + } + } + pattern += str[j++]; + } + if (count) + throw new TypeError("Unbalanced pattern at ".concat(i)); + if (!pattern) + throw new TypeError("Missing pattern at ".concat(i)); + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + tokens.push({ type: "END", index: i, value: "" }); + return tokens; +} +/** + * Parse a string for the raw tokens. + */ +function parse(str, options) { + if (options === void 0) { options = {}; } + var tokens = lexer(str); + var _a = options.prefixes, prefixes = _a === void 0 ? "./" : _a, _b = options.delimiter, delimiter = _b === void 0 ? "/#?" : _b; + var result = []; + var key = 0; + var i = 0; + var path = ""; + var tryConsume = function (type) { + if (i < tokens.length && tokens[i].type === type) + return tokens[i++].value; + }; + var mustConsume = function (type) { + var value = tryConsume(type); + if (value !== undefined) + return value; + var _a = tokens[i], nextType = _a.type, index = _a.index; + throw new TypeError("Unexpected ".concat(nextType, " at ").concat(index, ", expected ").concat(type)); + }; + var consumeText = function () { + var result = ""; + var value; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + var isSafe = function (value) { + for (var _i = 0, delimiter_1 = delimiter; _i < delimiter_1.length; _i++) { + var char = delimiter_1[_i]; + if (value.indexOf(char) > -1) + return true; + } + return false; + }; + var safePattern = function (prefix) { + var prev = result[result.length - 1]; + var prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + if (prev && !prevText) { + throw new TypeError("Must have text between two parameters, missing text after \"".concat(prev.name, "\"")); + } + if (!prevText || isSafe(prevText)) + return "[^".concat(escapeString(delimiter), "]+?"); + return "(?:(?!".concat(escapeString(prevText), ")[^").concat(escapeString(delimiter), "])+?"); + }; + while (i < tokens.length) { + var char = tryConsume("CHAR"); + var name = tryConsume("NAME"); + var pattern = tryConsume("PATTERN"); + if (name || pattern) { + var prefix = char || ""; + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + if (path) { + result.push(path); + path = ""; + } + result.push({ + name: name || key++, + prefix: prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + var value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + if (path) { + result.push(path); + path = ""; + } + var open = tryConsume("OPEN"); + if (open) { + var prefix = consumeText(); + var name_1 = tryConsume("NAME") || ""; + var pattern_1 = tryConsume("PATTERN") || ""; + var suffix = consumeText(); + mustConsume("CLOSE"); + result.push({ + name: name_1 || (pattern_1 ? key++ : ""), + pattern: name_1 && !pattern_1 ? safePattern(prefix) : pattern_1, + prefix: prefix, + suffix: suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + mustConsume("END"); + } + return result; +} +exports.parse = parse; +/** + * Compile a string to a template function for the path. + */ +function compile(str, options) { + return tokensToFunction(parse(str, options), options); +} +exports.compile = compile; +/** + * Expose a method for transforming tokens into the path function. + */ +function tokensToFunction(tokens, options) { + if (options === void 0) { options = {}; } + var reFlags = flags(options); + var _a = options.encode, encode = _a === void 0 ? function (x) { return x; } : _a, _b = options.validate, validate = _b === void 0 ? true : _b; + // Compile all the tokens into regexps. + var matches = tokens.map(function (token) { + if (typeof token === "object") { + return new RegExp("^(?:".concat(token.pattern, ")$"), reFlags); + } + }); + return function (data) { + var path = ""; + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (typeof token === "string") { + path += token; + continue; + } + var value = data ? data[token.name] : undefined; + var optional = token.modifier === "?" || token.modifier === "*"; + var repeat = token.modifier === "*" || token.modifier === "+"; + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError("Expected \"".concat(token.name, "\" to not repeat, but got an array")); + } + if (value.length === 0) { + if (optional) + continue; + throw new TypeError("Expected \"".concat(token.name, "\" to not be empty")); + } + for (var j = 0; j < value.length; j++) { + var segment = encode(value[j], token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected all \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + } + continue; + } + if (typeof value === "string" || typeof value === "number") { + var segment = encode(String(value), token); + if (validate && !matches[i].test(segment)) { + throw new TypeError("Expected \"".concat(token.name, "\" to match \"").concat(token.pattern, "\", but got \"").concat(segment, "\"")); + } + path += token.prefix + segment + token.suffix; + continue; + } + if (optional) + continue; + var typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError("Expected \"".concat(token.name, "\" to be ").concat(typeOfMessage)); + } + return path; + }; +} +exports.tokensToFunction = tokensToFunction; +/** + * Create path match function from `path-to-regexp` spec. + */ +function match(str, options) { + var keys = []; + var re = pathToRegexp(str, keys, options); + return regexpToFunction(re, keys, options); +} +exports.match = match; +/** + * Create a path match function from `path-to-regexp` output. + */ +function regexpToFunction(re, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.decode, decode = _a === void 0 ? function (x) { return x; } : _a; + return function (pathname) { + var m = re.exec(pathname); + if (!m) + return false; + var path = m[0], index = m.index; + var params = Object.create(null); + var _loop_1 = function (i) { + if (m[i] === undefined) + return "continue"; + var key = keys[i - 1]; + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map(function (value) { + return decode(value, key); + }); + } + else { + params[key.name] = decode(m[i], key); + } + }; + for (var i = 1; i < m.length; i++) { + _loop_1(i); + } + return { path: path, index: index, params: params }; + }; +} +exports.regexpToFunction = regexpToFunction; +/** + * Escape a regular expression string. + */ +function escapeString(str) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} +/** + * Get the flags for a regexp from the options. + */ +function flags(options) { + return options && options.sensitive ? "" : "i"; +} +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path, keys) { + if (!keys) + return path; + var groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + var index = 0; + var execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + return path; +} +/** + * Transform an array into a regexp. + */ +function arrayToRegexp(paths, keys, options) { + var parts = paths.map(function (path) { return pathToRegexp(path, keys, options).source; }); + return new RegExp("(?:".concat(parts.join("|"), ")"), flags(options)); +} +/** + * Create a path regexp from string input. + */ +function stringToRegexp(path, keys, options) { + return tokensToRegexp(parse(path, options), keys, options); +} +/** + * Expose a function for taking tokens and returning a RegExp. + */ +function tokensToRegexp(tokens, keys, options) { + if (options === void 0) { options = {}; } + var _a = options.strict, strict = _a === void 0 ? false : _a, _b = options.start, start = _b === void 0 ? true : _b, _c = options.end, end = _c === void 0 ? true : _c, _d = options.encode, encode = _d === void 0 ? function (x) { return x; } : _d, _e = options.delimiter, delimiter = _e === void 0 ? "/#?" : _e, _f = options.endsWith, endsWith = _f === void 0 ? "" : _f; + var endsWithRe = "[".concat(escapeString(endsWith), "]|$"); + var delimiterRe = "[".concat(escapeString(delimiter), "]"); + var route = start ? "^" : ""; + // Iterate over the tokens and create our regexp string. + for (var _i = 0, tokens_1 = tokens; _i < tokens_1.length; _i++) { + var token = tokens_1[_i]; + if (typeof token === "string") { + route += escapeString(encode(token)); + } + else { + var prefix = escapeString(encode(token.prefix)); + var suffix = escapeString(encode(token.suffix)); + if (token.pattern) { + if (keys) + keys.push(token); + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + var mod = token.modifier === "*" ? "?" : ""; + route += "(?:".concat(prefix, "((?:").concat(token.pattern, ")(?:").concat(suffix).concat(prefix, "(?:").concat(token.pattern, "))*)").concat(suffix, ")").concat(mod); + } + else { + route += "(?:".concat(prefix, "(").concat(token.pattern, ")").concat(suffix, ")").concat(token.modifier); + } + } + else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError("Can not repeat \"".concat(token.name, "\" without a prefix and suffix")); + } + route += "(".concat(token.pattern, ")").concat(token.modifier); + } + } + else { + route += "(?:".concat(prefix).concat(suffix, ")").concat(token.modifier); + } + } + } + if (end) { + if (!strict) + route += "".concat(delimiterRe, "?"); + route += !options.endsWith ? "$" : "(?=".concat(endsWithRe, ")"); + } + else { + var endToken = tokens[tokens.length - 1]; + var isEndDelimited = typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + if (!strict) { + route += "(?:".concat(delimiterRe, "(?=").concat(endsWithRe, "))?"); + } + if (!isEndDelimited) { + route += "(?=".concat(delimiterRe, "|").concat(endsWithRe, ")"); + } + } + return new RegExp(route, flags(options)); +} +exports.tokensToRegexp = tokensToRegexp; +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +function pathToRegexp(path, keys, options) { + if (path instanceof RegExp) + return regexpToRegexp(path, keys); + if (Array.isArray(path)) + return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} +exports.pathToRegexp = pathToRegexp; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json new file mode 100644 index 0000000000..aa7a4f6681 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp/package.json @@ -0,0 +1,63 @@ +{ + "name": "path-to-regexp", + "version": "6.3.0", + "description": "Express style path to RegExp utility", + "keywords": [ + "express", + "regexp", + "route", + "routing" + ], + "repository": { + "type": "git", + "url": "https://github.com/pillarjs/path-to-regexp.git" + }, + "license": "MIT", + "sideEffects": false, + "main": "dist/index.js", + "module": "dist.es2015/index.js", + "typings": "dist/index.d.ts", + "files": [ + "dist.es2015/", + "dist/" + ], + "scripts": { + "build": "ts-scripts build", + "format": "ts-scripts format", + "lint": "ts-scripts lint", + "prepare": "ts-scripts install && npm run build", + "size": "size-limit", + "specs": "ts-scripts specs", + "test": "ts-scripts test && npm run size" + }, + "devDependencies": { + "@borderless/ts-scripts": "^0.15.0", + "@size-limit/preset-small-lib": "^11.1.2", + "@types/node": "^20.4.9", + "@types/semver": "^7.3.1", + "@vitest/coverage-v8": "^1.4.0", + "recheck": "^4.4.5", + "semver": "^7.3.5", + "size-limit": "^11.1.2", + "typescript": "^5.1.6" + }, + "publishConfig": { + "access": "public" + }, + "size-limit": [ + { + "path": "dist.es2015/index.js", + "limit": "2.1 kB" + } + ], + "ts-scripts": { + "dist": [ + "dist", + "dist.es2015" + ], + "project": [ + "tsconfig.build.json", + "tsconfig.es2015.json" + ] + } +} diff --git a/tests/fixtures/source-overlays/project/node_modules/@openfeature/core b/tests/fixtures/source-overlays/project/node_modules/@openfeature/core new file mode 120000 index 0000000000..5f4d646608 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/@openfeature/core @@ -0,0 +1 @@ +../.pnpm/@openfeature+core@1.9.2/node_modules/@openfeature/core \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk b/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk new file mode 120000 index 0000000000..66f36b5846 --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/@openfeature/server-sdk @@ -0,0 +1 @@ +../.pnpm/@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2/node_modules/@openfeature/server-sdk \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/mitt b/tests/fixtures/source-overlays/project/node_modules/mitt new file mode 120000 index 0000000000..5ebf4e45cf --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/mitt @@ -0,0 +1 @@ +.pnpm/mitt@3.0.1/node_modules/mitt \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/node_modules/path-to-regexp b/tests/fixtures/source-overlays/project/node_modules/path-to-regexp new file mode 120000 index 0000000000..81a20318fa --- /dev/null +++ b/tests/fixtures/source-overlays/project/node_modules/path-to-regexp @@ -0,0 +1 @@ +.pnpm/path-to-regexp@6.3.0/node_modules/path-to-regexp \ No newline at end of file diff --git a/tests/fixtures/source-overlays/project/package.json b/tests/fixtures/source-overlays/project/package.json new file mode 100644 index 0000000000..e9058f7fa9 --- /dev/null +++ b/tests/fixtures/source-overlays/project/package.json @@ -0,0 +1 @@ +{"name":"spm-fixture","private":true,"type":"module","dependencies":{"@openfeature/core":"1.9.2","@openfeature/server-sdk":"1.18.0","mitt":"3.0.1","path-to-regexp":"6.3.0"}} diff --git a/tests/fixtures/source-overlays/project/require.cts b/tests/fixtures/source-overlays/project/require.cts new file mode 100644 index 0000000000..16f658c607 --- /dev/null +++ b/tests/fixtures/source-overlays/project/require.cts @@ -0,0 +1,3 @@ +const { pathToRegexp } = require("path-to-regexp") as typeof import("path-to-regexp"); + +console.log(pathToRegexp("/a/:b").source); diff --git a/tests/fixtures/source-overlays/project/routes.ts b/tests/fixtures/source-overlays/project/routes.ts new file mode 100644 index 0000000000..a826d0ef42 --- /dev/null +++ b/tests/fixtures/source-overlays/project/routes.ts @@ -0,0 +1,18 @@ +import { parse } from "path-to-regexp"; + +for (const route of ["/a/:b/(\\d+)/:c?", "/files/:dir+/:name.json", "/:lang(en|de)/docs/:rest*"]) { + const tokens = parse(route); + console.log(route, tokens.length); + for (const token of tokens) { + console.log( + typeof token === "string" + ? ` text ${token}` + : ` key ${token.name} prefix=${token.prefix} pattern=${token.pattern} modifier=${token.modifier}`, + ); + } +} +try { + parse("/:bad("); +} catch (e) { + console.log("error", (e as Error).message); +} diff --git a/tests/fixtures/source-overlays/sources/core/LICENSE b/tests/fixtures/source-overlays/sources/core/LICENSE new file mode 100644 index 0000000000..96b3dc8fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright OpenFeature Maintainers + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/sources/core/src/client/client.ts b/tests/fixtures/source-overlays/sources/core/src/client/client.ts new file mode 100644 index 0000000000..f487e9ddb9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/client/client.ts @@ -0,0 +1,11 @@ +import type { ProviderMetadata } from '../provider/provider'; + +export interface ClientMetadata { + /** + * @deprecated alias of "domain", use domain instead + */ + readonly name?: string; + readonly domain?: string; + readonly version?: string; + readonly providerMetadata: ProviderMetadata; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/client/index.ts b/tests/fixtures/source-overlays/sources/core/src/client/index.ts new file mode 100644 index 0000000000..4f1cce44fa --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/client/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts new file mode 100644 index 0000000000..b1bc128523 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/flag-not-found-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class FlagNotFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, FlagNotFoundError.prototype); + this.name = 'FlagNotFoundError'; + this.code = ErrorCode.FLAG_NOT_FOUND; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts new file mode 100644 index 0000000000..a78caf8a42 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/general-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class GeneralError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, GeneralError.prototype); + this.name = 'GeneralError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/index.ts b/tests/fixtures/source-overlays/sources/core/src/errors/index.ts new file mode 100644 index 0000000000..bd87e965af --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/index.ts @@ -0,0 +1,45 @@ +import { ErrorCode } from '../evaluation'; + +import { FlagNotFoundError } from './flag-not-found-error'; +import { GeneralError } from './general-error'; +import { InvalidContextError } from './invalid-context-error'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ParseError } from './parse-error'; +import { ProviderFatalError } from './provider-fatal-error'; +import { ProviderNotReadyError } from './provider-not-ready-error'; +import { TargetingKeyMissingError } from './targeting-key-missing-error'; +import { TypeMismatchError } from './type-mismatch-error'; + +const instantiateErrorByErrorCode = (errorCode: ErrorCode, message?: string): OpenFeatureError => { + switch (errorCode) { + case ErrorCode.FLAG_NOT_FOUND: + return new FlagNotFoundError(message); + case ErrorCode.PARSE_ERROR: + return new ParseError(message); + case ErrorCode.TYPE_MISMATCH: + return new TypeMismatchError(message); + case ErrorCode.TARGETING_KEY_MISSING: + return new TargetingKeyMissingError(message); + case ErrorCode.INVALID_CONTEXT: + return new InvalidContextError(message); + case ErrorCode.PROVIDER_NOT_READY: + return new ProviderNotReadyError(message); + case ErrorCode.PROVIDER_FATAL: + return new ProviderFatalError(message); + default: + return new GeneralError(message); + } +}; + +export { + FlagNotFoundError, + GeneralError, + InvalidContextError, + ParseError, + ProviderFatalError, + ProviderNotReadyError, + TargetingKeyMissingError, + TypeMismatchError, + OpenFeatureError, + instantiateErrorByErrorCode, +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts new file mode 100644 index 0000000000..3213ee0a0d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/invalid-context-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class InvalidContextError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, InvalidContextError.prototype); + this.name = 'InvalidContextError'; + this.code = ErrorCode.INVALID_CONTEXT; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts b/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts new file mode 100644 index 0000000000..9e2f5e2233 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/open-feature-error-abstract.ts @@ -0,0 +1,20 @@ +import type { ErrorCode } from '../evaluation'; + +/** + * Error Options were added in ES2022. Manually adding the type so that an + * earlier target can be used. + */ +export type ErrorOptions = { + cause?: unknown; +}; + +export abstract class OpenFeatureError extends Error { + abstract code: ErrorCode; + cause?: unknown; + constructor(message?: string, options?: ErrorOptions) { + super(message); + Object.setPrototypeOf(this, OpenFeatureError.prototype); + this.name = 'OpenFeatureError'; + this.cause = options?.cause; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts new file mode 100644 index 0000000000..2d948077df --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/parse-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ParseError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ParseError.prototype); + this.name = 'ParseError'; + this.code = ErrorCode.PARSE_ERROR; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts new file mode 100644 index 0000000000..1d9fd8a2bd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/provider-fatal-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ProviderFatalError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ProviderFatalError.prototype); + this.name = 'ProviderFatalError'; + this.code = ErrorCode.PROVIDER_FATAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts new file mode 100644 index 0000000000..02fd2cf48d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/provider-not-ready-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class ProviderNotReadyError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, ProviderNotReadyError.prototype); + this.name = 'ProviderNotReadyError'; + this.code = ErrorCode.PROVIDER_NOT_READY; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts new file mode 100644 index 0000000000..dcd4e04943 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/targeting-key-missing-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class TargetingKeyMissingError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, TargetingKeyMissingError.prototype); + this.name = 'TargetingKeyMissingError'; + this.code = ErrorCode.TARGETING_KEY_MISSING; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts b/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts new file mode 100644 index 0000000000..80b3b87b48 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/errors/type-mismatch-error.ts @@ -0,0 +1,13 @@ +import type { ErrorOptions } from './open-feature-error-abstract'; +import { OpenFeatureError } from './open-feature-error-abstract'; +import { ErrorCode } from '../evaluation'; + +export class TypeMismatchError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, TypeMismatchError.prototype); + this.name = 'TypeMismatchError'; + this.code = ErrorCode.TYPE_MISMATCH; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts new file mode 100644 index 0000000000..db4f9597cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/context.ts @@ -0,0 +1,36 @@ +import type { PrimitiveValue } from '../types'; + +export type EvaluationContextValue = + | PrimitiveValue + | Date + | { [key: string]: EvaluationContextValue } + | EvaluationContextValue[]; + +/** + * A container for arbitrary contextual data that can be used as a basis for dynamic evaluation + */ +export type EvaluationContext = { + /** + * A string uniquely identifying the subject (end-user, or client service) of a flag evaluation. + * Providers may require this field for fractional flag evaluation, rules, or overrides targeting specific users. + * Such providers may behave unpredictably if a targeting key is not specified at flag resolution. + */ + targetingKey?: string; +} & Record; + +export interface ManageContext { + /** + * Access the evaluation context set on the receiver. + * @returns {EvaluationContext} Evaluation context + */ + getContext(): EvaluationContext; + + /** + * Sets evaluation context that will be used during flag evaluations + * on this receiver. + * @template T The type of the receiver + * @param {EvaluationContext} context Evaluation context + * @returns {T} The receiver (this object) + */ + setContext(context: EvaluationContext): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts new file mode 100644 index 0000000000..78e7b65cbb --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/evaluation.ts @@ -0,0 +1,122 @@ +import type { JsonValue } from '../types/structure'; + +export type FlagValueType = 'boolean' | 'string' | 'number' | 'object'; + +/** + * Represents a JSON node value. + */ +export type FlagValue = boolean | string | number | JsonValue; + +export type ResolutionReason = keyof typeof StandardResolutionReasons | (string & Record); + +/** + * A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + * + * This structure is populated by a provider for use by an Application Author (via the Evaluation API) or an Application Integrator (via hooks). + */ +export type FlagMetadata = Record; + +export type ResolutionDetails = { + value: U; + variant?: string; + flagMetadata?: FlagMetadata; + reason?: ResolutionReason; + errorCode?: ErrorCode; + errorMessage?: string; +}; + +export type EvaluationDetails = { + flagKey: string; + flagMetadata: Readonly; +} & ResolutionDetails; + +export const StandardResolutionReasons = { + /** + * The resolved value is static (no dynamic evaluation). + */ + STATIC: 'STATIC', + + /** + * The resolved value was configured statically, or otherwise fell back to a pre-configured value. + */ + DEFAULT: 'DEFAULT', + + /** + * The resolved value was the result of a dynamic evaluation, such as a rule or specific user-targeting. + */ + TARGETING_MATCH: 'TARGETING_MATCH', + + /** + * The resolved value was the result of pseudorandom assignment. + */ + SPLIT: 'SPLIT', + + /** + * The resolved value was retrieved from cache. + */ + CACHED: 'CACHED', + + /** + * The resolved value was the result of the flag being disabled in the management system. + */ + DISABLED: 'DISABLED', + + /** + * The reason for the resolved value could not be determined. + */ + UNKNOWN: 'UNKNOWN', + + /** + * The resolved value is non-authoritative or possibly out of date. + */ + STALE: 'STALE', + + /** + * The resolved value was the result of an error. + * + * Note: The `errorCode` and `errorMessage` fields may contain additional details of this error. + */ + ERROR: 'ERROR', +} as const; + +export enum ErrorCode { + /** + * The value was resolved before the provider was ready. + */ + PROVIDER_NOT_READY = 'PROVIDER_NOT_READY', + + /** + * The provider has entered an irrecoverable error state. + */ + PROVIDER_FATAL = 'PROVIDER_FATAL', + + /** + * The flag could not be found. + */ + FLAG_NOT_FOUND = 'FLAG_NOT_FOUND', + + /** + * An error was encountered parsing data, such as a flag configuration. + */ + PARSE_ERROR = 'PARSE_ERROR', + + /** + * The type of the flag value does not match the expected type. + */ + TYPE_MISMATCH = 'TYPE_MISMATCH', + + /** + * The provider requires a targeting key and one was not provided in the evaluation context. + */ + TARGETING_KEY_MISSING = 'TARGETING_KEY_MISSING', + + /** + * The evaluation context does not meet provider requirements. + */ + INVALID_CONTEXT = 'INVALID_CONTEXT', + + /** + * An error with an unspecified code. + */ + GENERAL = 'GENERAL', +} diff --git a/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts b/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts new file mode 100644 index 0000000000..3f94fc79a8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/evaluation/index.ts @@ -0,0 +1,2 @@ +export * from './context'; +export * from './evaluation'; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts b/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts new file mode 100644 index 0000000000..c840eef2ea --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/event-utils.ts @@ -0,0 +1,27 @@ +import type { ClientProviderStatus, ServerProviderStatus } from '../provider'; +import { AllProviderStatus } from '../provider'; +import type { AnyProviderEvent } from './events'; +import { AllProviderEvents } from './events'; + +const eventStatusMap = { + [AllProviderStatus.READY]: AllProviderEvents.Ready, + [AllProviderStatus.ERROR]: AllProviderEvents.Error, + [AllProviderStatus.FATAL]: AllProviderEvents.Error, + [AllProviderStatus.STALE]: AllProviderEvents.Stale, + [AllProviderStatus.RECONCILING]: AllProviderEvents.Reconciling, + [AllProviderStatus.NOT_READY]: undefined, +}; + +/** + * Returns true if the provider's status corresponds to the event. + * If the provider's status is not defined, it matches READY. + * @param {AnyProviderEvent} event event to match + * @param {ClientProviderStatus | ServerProviderStatus} status status of provider + * @returns {boolean} boolean indicating if the provider status corresponds to the event. + */ +export const statusMatchesEvent = ( + event: T, + status?: ClientProviderStatus | ServerProviderStatus, +): boolean => { + return (!status && event === AllProviderEvents.Ready) || eventStatusMap[status!] === event; +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts b/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts new file mode 100644 index 0000000000..952b6e1dc2 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/eventing.ts @@ -0,0 +1,113 @@ +import type { ErrorCode } from '../evaluation'; +import type { ClientProviderEvents, ServerProviderEvents } from './events'; +import { AnyProviderEvent } from './events'; + +export type EventMetadata = { + [key: string]: string | boolean | number; +}; + +export type CommonEventDetails = { + readonly providerName: string; + /** + * @deprecated alias of "domain", use domain instead + */ + readonly clientName?: string; + readonly domain?: string; +}; + +type CommonEventProps = { + readonly message?: string; + readonly metadata?: EventMetadata; +}; + +export type ReadyEvent = CommonEventProps; +export type ErrorEvent = CommonEventProps; +export type StaleEvent = CommonEventProps; +export type ReconcilingEvent = CommonEventProps & { readonly errorCode: ErrorCode }; +export type ConfigChangeEvent = CommonEventProps & { readonly flagsChanged?: string[] }; + +type ServerEventMap = { + [ServerProviderEvents.Ready]: ReadyEvent; + [ServerProviderEvents.Error]: ErrorEvent; + [ServerProviderEvents.Stale]: StaleEvent; + [ServerProviderEvents.ConfigurationChanged]: ConfigChangeEvent; +}; + +type ClientEventMap = { + [ClientProviderEvents.Ready]: ReadyEvent; + [ClientProviderEvents.Error]: ErrorEvent; + [ClientProviderEvents.Stale]: StaleEvent; + [ClientProviderEvents.ConfigurationChanged]: ConfigChangeEvent; + [ClientProviderEvents.Reconciling]: CommonEventProps; + [ClientProviderEvents.ContextChanged]: CommonEventProps; +}; + +type ServerNotChangeEvents = ServerProviderEvents.Ready | ServerProviderEvents.Error | ServerProviderEvents.Stale; +type ClientNotChangeEvents = + | ClientProviderEvents.Ready + | ClientProviderEvents.Error + | ClientProviderEvents.Stale + | ClientProviderEvents.ContextChanged + | ClientProviderEvents.Reconciling; +export type NotChangeEvents = ServerNotChangeEvents | ClientNotChangeEvents; + +export type EventContext< + U extends Record = Record, + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = (T extends ClientProviderEvents ? ClientEventMap[T] : T extends ServerProviderEvents ? ServerEventMap[T] : never) & + U; + +export type EventDetails< + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = EventContext, T> & CommonEventDetails; +export type EventHandler< + T extends ServerProviderEvents | ClientProviderEvents = ServerProviderEvents | ClientProviderEvents, +> = (eventDetails?: EventDetails) => Promise | unknown; +export type EventOptions = { + signal?: AbortSignal; +}; + +export interface Eventing { + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * @param eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler( + eventType: T extends ClientProviderEvents + ? ClientProviderEvents.ConfigurationChanged + : ServerProviderEvents.ConfigurationChanged, + handler: EventHandler< + T extends ClientProviderEvents + ? ClientProviderEvents.ConfigurationChanged + : ServerProviderEvents.ConfigurationChanged + >, + options?: EventOptions, + ): void; + addHandler( + eventType: T extends ClientProviderEvents ? ClientNotChangeEvents : ServerNotChangeEvents, + handler: EventHandler, + options?: EventOptions, + ): void; + addHandler( + eventType: T extends ClientProviderEvents ? ClientProviderEvents : ServerProviderEvents, + handler: EventHandler, + options?: EventOptions, + ): void; + + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void; + + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[]; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/events/events.ts b/tests/fixtures/source-overlays/sources/core/src/events/events.ts new file mode 100644 index 0000000000..e32f6d564f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/events.ts @@ -0,0 +1,73 @@ +// TODO: with TypeScript 5+, we can use computed string properties, +// so we can extract all of these into a shared set of string consts and use that in both enums +// for now we have duplicated them. + +/** + * An enumeration of possible events for server-sdk providers. + */ +export enum ServerProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = 'PROVIDER_READY', + + /** + * The provider is in an error state. + */ + Error = 'PROVIDER_ERROR', + + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = 'PROVIDER_CONFIGURATION_CHANGED', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = 'PROVIDER_STALE', +} + +/** + * An enumeration of possible events for web-sdk providers. + */ +export enum ClientProviderEvents { + /** + * The provider is ready to evaluate flags. + */ + Ready = 'PROVIDER_READY', + + /** + * The provider is in an error state. + */ + Error = 'PROVIDER_ERROR', + + /** + * The flag configuration in the source-of-truth has changed. + */ + ConfigurationChanged = 'PROVIDER_CONFIGURATION_CHANGED', + + /** + * The context associated with the provider has changed, and the provider has reconciled it's associated state. + */ + ContextChanged = 'PROVIDER_CONTEXT_CHANGED', + + /** + * The context associated with the provider has changed, and the provider has not yet reconciled its associated state. + */ + Reconciling = 'PROVIDER_RECONCILING', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + Stale = 'PROVIDER_STALE', +} + +/* alias because in many cases, we iterate over all possible events in code, +so we have to do this on ClientProviderEvents to be exhaustive */ +export { ClientProviderEvents as AllProviderEvents }; + +/** + * A type representing any possible ProviderEvent (server or client side). + * In most cases, you probably want to import `ProviderEvents` from the respective SDK. + */ +export type AnyProviderEvent = ServerProviderEvents | ClientProviderEvents; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts b/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts new file mode 100644 index 0000000000..915470a4bd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/generic-event-emitter.ts @@ -0,0 +1,110 @@ +import type { Logger, ManageLogger } from '../logger'; +import { SafeLogger } from '../logger'; +import type { ProviderEventEmitter } from './provider-event-emitter'; +import type { EventContext, EventDetails, EventHandler } from './eventing'; +import type { AnyProviderEvent } from './events'; +import { AllProviderEvents } from './events'; + +/** + * The GenericEventEmitter should only be used within the SDK. It supports additional properties that can be included + * in the event details. + */ +export abstract class GenericEventEmitter< + E extends AnyProviderEvent, + AdditionalContext extends Record = Record, +> + implements ProviderEventEmitter, ManageLogger> +{ + protected abstract readonly eventEmitter: PlatformEventEmitter; + + private readonly _handlers: { [key in AnyProviderEvent]: WeakMap } = { + [AllProviderEvents.ConfigurationChanged]: new WeakMap(), + [AllProviderEvents.ContextChanged]: new WeakMap(), + [AllProviderEvents.Ready]: new WeakMap(), + [AllProviderEvents.Error]: new WeakMap(), + [AllProviderEvents.Stale]: new WeakMap(), + [AllProviderEvents.Reconciling]: new WeakMap(), + }; + private _eventLogger?: Logger; + + constructor(private readonly globalLogger?: () => Logger) {} + + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType: E, context?: EventContext): void { + this.eventEmitter.emit(eventType, context); + } + + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void { + // The handlers have to be wrapped with an async function because if a synchronous functions throws an error, + // the other handlers will not run. + const asyncHandler = async (details?: EventDetails) => { + try { + await handler(details); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + }; + // The async handler has to be written to the map, because we need to get the wrapper function when deleting a listener + const existingAsyncHandlers = this._handlers[eventType].get(handler); + + // we allow duplicate event handlers, similar to node, + // see: https://nodejs.org/api/events.html#emitteroneventname-listener + // and https://nodejs.org/api/events.html#emitterremovelistenereventname-listener + this._handlers[eventType].set(handler, [...(existingAsyncHandlers || []), asyncHandler]); + this.eventEmitter.on(eventType, asyncHandler); + } + + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void { + // Get the wrapper function for this handler, to delete it from the event emitter + const existingAsyncHandlers = this._handlers[eventType].get(handler); + + if (existingAsyncHandlers) { + const removedAsyncHandler = existingAsyncHandlers.pop(); + if (removedAsyncHandler) { + this.eventEmitter.removeListener(eventType, removedAsyncHandler); + } + } + } + + removeAllHandlers(eventType?: AnyProviderEvent): void { + // If giving undefined, the listeners are not removed, so we have to check explicitly + if (eventType) { + this.eventEmitter.removeAllListeners(eventType); + } else { + this.eventEmitter.removeAllListeners(); + } + } + + getHandlers(eventType: AnyProviderEvent): EventHandler[] { + return this.eventEmitter.listeners(eventType) as EventHandler[]; + } + + setLogger(logger: Logger): this { + this._eventLogger = new SafeLogger(logger); + return this; + } + + protected get _logger() { + return this._eventLogger ?? this.globalLogger?.(); + } +} + +/** + * This is an un-exported type that corresponds to NodeJS.EventEmitter. + * We can't use that type here, because this module is used in both the browser, and the server. + * In the server, node (or whatever server runtime) provides an implementation for this. + * In the browser, we bundle in the popular 'EventEmitter3' package, which is a polyfill of NodeJS.EventEmitter. + */ +/* eslint-disable */ +interface PlatformEventEmitter { + addListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + on(eventName: string | symbol, listener: (...args: any[]) => void): this; + once(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeListener(eventName: string | symbol, listener: (...args: any[]) => void): this; + off(eventName: string | symbol, listener: (...args: any[]) => void): this; + removeAllListeners(event?: string | symbol): this; + listeners(eventName: string | symbol): Function[]; + emit(eventName: string | symbol, ...args: any[]): boolean; + listenerCount(eventName: string | symbol, listener?: Function): number; + eventNames(): Array; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/events/index.ts b/tests/fixtures/source-overlays/sources/core/src/events/index.ts new file mode 100644 index 0000000000..1e811b6907 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/index.ts @@ -0,0 +1,5 @@ +export * from './provider-event-emitter'; +export * from './event-utils'; +export * from './eventing'; +export * from './events'; +export * from './generic-event-emitter'; diff --git a/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts b/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts new file mode 100644 index 0000000000..cec0088c99 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/events/provider-event-emitter.ts @@ -0,0 +1,23 @@ +import type { ManageLogger } from '../logger'; +import type { EventContext, EventHandler } from './eventing'; +import type { AnyProviderEvent } from './events'; + +/** + * Event emitter to be optionally implemented by providers. + * Implemented by @see OpenFeatureEventEmitter. + */ +export interface ProviderEventEmitter< + E extends AnyProviderEvent, + AdditionalContext extends Record = Record, +> extends ManageLogger> { + // here we use E, to restrict the events a provider can manually emit (PROVIDER_CONTEXT_CHANGED is emitted by the SDK) + emit(eventType: E, context?: EventContext): void; + + addHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + + removeHandler(eventType: AnyProviderEvent, handler: EventHandler): void; + + removeAllHandlers(eventType?: AnyProviderEvent): void; + + getHandlers(eventType: AnyProviderEvent): EventHandler[]; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/filter.ts b/tests/fixtures/source-overlays/sources/core/src/filter.ts new file mode 100644 index 0000000000..f0ab489fc4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/filter.ts @@ -0,0 +1,9 @@ +/** + * Checks if a value is not null or undefined and returns it as type assertion + * @template T + * @param {T} input The value to check + * @returns {T} If the value is not null or undefined + */ +export function isDefined(input?: T | null | undefined): input is T { + return typeof input !== 'undefined' && input !== null; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts new file mode 100644 index 0000000000..361f9c3de9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/evaluation-lifecycle.ts @@ -0,0 +1,28 @@ +import type { BaseHook } from './hook'; + +export interface EvaluationLifeCycle { + /** + * Adds hooks that will run during flag evaluations on this receiver. + * Hooks are executed in the order they were registered. Adding additional hooks + * will not remove existing hooks. + * Hooks registered on the global API object run with all evaluations. + * Hooks registered on the client run with all evaluations on that client. + * @template T The type of the receiver + * @param {BaseHook[]} hooks A list of hooks that should always run + * @returns {T} The receiver (this object) + */ + addHooks(...hooks: BaseHook[]): T; + + /** + * Access all the hooks that are registered on this receiver. + * @returns {BaseHook[]} A list of the client hooks + */ + getHooks(): BaseHook[]; + + /** + * Clears all the hooks that are registered on this receiver. + * @template T The type of the receiver + * @returns {T} The receiver (this object) + */ + clearHooks(): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts new file mode 100644 index 0000000000..afd9f9f3c4 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hook-data.ts @@ -0,0 +1,72 @@ +/** + * A mutable data structure for hooks to maintain state across their lifecycle. + * Each hook instance gets its own isolated data store that persists for the + * duration of a single flag evaluation. + * @template TData - A record type that defines the shape of the stored data + */ +export interface HookData> { + /** + * Sets a value in the hook data store. + * @param key The key to store the value under + * @param value The value to store + */ + set(key: K, value: TData[K]): void; + set(key: string, value: unknown): void; + + /** + * Gets a value from the hook data store. + * @param key The key to retrieve the value for + * @returns The stored value, or undefined if not found + */ + get(key: K): TData[K] | undefined; + get(key: string): unknown; + + /** + * Checks if a key exists in the hook data store. + * @param key The key to check + * @returns True if the key exists, false otherwise + */ + has(key: K): boolean; + has(key: string): boolean; + + /** + * Deletes a value from the hook data store. + * @param key The key to delete + * @returns True if the key was deleted, false if it didn't exist + */ + delete(key: K): boolean; + delete(key: string): boolean; + + /** + * Clears all values from the hook data store. + */ + clear(): void; +} + +/** + * Default implementation of HookData using a Map. + * @template TData - A record type that defines the shape of the stored data + */ +export class MapHookData> implements HookData { + private readonly data = new Map(); + + set(key: K, value: TData[K]): void { + this.data.set(key, value); + } + + get(key: K): TData[K] | undefined { + return this.data.get(key) as TData[K] | undefined; + } + + has(key: K): boolean { + return this.data.has(key); + } + + delete(key: K): boolean { + return this.data.delete(key); + } + + clear(): void { + this.data.clear(); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts new file mode 100644 index 0000000000..3c21c04dac --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hook.ts @@ -0,0 +1,49 @@ +import type { BeforeHookContext, HookContext, HookHints } from './hooks'; +import type { EvaluationDetails, FlagValue } from '../evaluation'; + +export interface BaseHook< + T extends FlagValue = FlagValue, + TData = Record, + BeforeHookReturn = unknown, + HooksReturn = unknown, +> { + /** + * Runs before flag values are resolved from the provider. + * If an EvaluationContext is returned, it will be merged with the pre-existing EvaluationContext. + * @param hookContext + * @param hookHints + */ + before?(hookContext: BeforeHookContext, hookHints?: HookHints): BeforeHookReturn; + + /** + * Runs after flag values are successfully resolved from the provider. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + after?( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + hookHints?: HookHints, + ): HooksReturn; + + /** + * Runs in the event of an unhandled error or promise rejection during flag resolution, or any attached hooks. + * @param hookContext + * @param error + * @param hookHints + */ + error?(hookContext: Readonly>, error: unknown, hookHints?: HookHints): HooksReturn; + + /** + * Runs after all other hook stages, regardless of success or error. + * @param hookContext + * @param evaluationDetails + * @param hookHints + */ + finally?( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, + hookHints?: HookHints, + ): HooksReturn; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts new file mode 100644 index 0000000000..58e0e553dd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/hooks.ts @@ -0,0 +1,25 @@ +import type { ProviderMetadata } from '../provider'; +import type { ClientMetadata } from '../client'; +import type { EvaluationContext, FlagValue, FlagValueType } from '../evaluation'; +import type { Logger } from '../logger'; +import type { HookData } from './hook-data'; + +export type HookHints = Readonly>; + +export interface HookContext> { + readonly flagKey: string; + readonly defaultValue: T; + readonly flagValueType: FlagValueType; + readonly context: Readonly; + readonly clientMetadata: ClientMetadata; + readonly providerMetadata: ProviderMetadata; + readonly logger: Logger; + readonly hookData: HookData; +} + +export interface BeforeHookContext< + T extends FlagValue = FlagValue, + TData = Record, +> extends HookContext { + context: EvaluationContext; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts b/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts new file mode 100644 index 0000000000..47227681a6 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './hook'; +export * from './hooks'; +export * from './evaluation-lifecycle'; +export * from './hook-data'; diff --git a/tests/fixtures/source-overlays/sources/core/src/index.ts b/tests/fixtures/source-overlays/sources/core/src/index.ts new file mode 100644 index 0000000000..c708fb9afd --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/index.ts @@ -0,0 +1,12 @@ +export * from './hooks'; +export * from './types'; +export * from './client'; +export * from './errors'; +export * from './events'; +export * from './logger'; +export * from './telemetry'; +export * from './provider'; +export * from './evaluation'; +export * from './type-guards'; +export * from './tracking'; +export * from './open-feature'; diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts new file mode 100644 index 0000000000..111ef61259 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/default-logger.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import type { Logger } from './logger'; + +export class DefaultLogger implements Logger { + error(...args: unknown[]): void { + console.error(...args); + } + + warn(...args: unknown[]): void { + console.warn(...args); + } + + info(): void {} + + debug(): void {} +} diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/index.ts b/tests/fixtures/source-overlays/sources/core/src/logger/index.ts new file mode 100644 index 0000000000..2c09cfaf44 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/index.ts @@ -0,0 +1,3 @@ +export * from './logger'; +export * from './default-logger'; +export * from './safe-logger'; diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts new file mode 100644 index 0000000000..04d9c06b82 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/logger.ts @@ -0,0 +1,22 @@ +export interface Logger { + error(...args: unknown[]): void; + + warn(...args: unknown[]): void; + + info(...args: unknown[]): void; + + debug(...args: unknown[]): void; +} + +export interface ManageLogger { + /** + * Sets a logger on this receiver. This logger supersedes to the global logger + * and is passed to various components in the SDK. + * The logger configured on the global API object will be used for all evaluations, + * unless overridden in a particular client. + * @template T The type of the receiver + * @param {Logger} logger The logger to be used + * @returns {T} The receiver (this object) + */ + setLogger(logger: Logger): T; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts b/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts new file mode 100644 index 0000000000..a860f3828c --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/logger/safe-logger.ts @@ -0,0 +1,48 @@ +import type { Logger } from './logger'; +import { DefaultLogger } from './default-logger'; + +export const LOG_LEVELS: Array = ['error', 'warn', 'info', 'debug']; + +export class SafeLogger implements Logger { + private readonly logger: Logger; + private readonly fallbackLogger = new DefaultLogger(); + + constructor(logger: Logger) { + try { + for (const level of LOG_LEVELS) { + if (!logger[level] || typeof logger[level] !== 'function') { + throw new Error(`The provided logger is missing the ${level} method.`); + } + } + this.logger = logger; + } catch (err) { + console.error(err); + console.error('Falling back to the default logger.'); + this.logger = this.fallbackLogger; + } + } + + error(...args: unknown[]): void { + this.log('error', ...args); + } + + warn(...args: unknown[]): void { + this.log('warn', ...args); + } + + info(...args: unknown[]): void { + this.log('info', ...args); + } + + debug(...args: unknown[]): void { + this.log('debug', ...args); + } + + private log(level: keyof Logger, ...args: unknown[]) { + try { + this.logger[level](...args); + } catch (error) { + this.fallbackLogger[level](...args); + } + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/open-feature.ts b/tests/fixtures/source-overlays/sources/core/src/open-feature.ts new file mode 100644 index 0000000000..00142b5670 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/open-feature.ts @@ -0,0 +1,443 @@ +import type { OpenFeatureError } from './errors'; +import { GeneralError } from './errors'; +import type { EvaluationContext } from './evaluation'; +import { ErrorCode } from './evaluation'; +import type { + AnyProviderEvent, + EventDetails, + EventHandler, + Eventing, + EventOptions, + GenericEventEmitter, +} from './events'; +import { AllProviderEvents, statusMatchesEvent } from './events'; +import { isDefined } from './filter'; +import type { BaseHook, EvaluationLifeCycle } from './hooks'; +import type { Logger, ManageLogger } from './logger'; +import { DefaultLogger, SafeLogger } from './logger'; +import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider'; +import { objectOrUndefined, stringOrUndefined } from './type-guards'; +import type { Paradigm } from './types'; + +type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus; + +/** + * A provider and its current status. + * For internal use only. + */ +export class ProviderWrapper

, S extends AnyProviderStatus> { + private _pendingContextChanges = 0; + + constructor( + private _provider: P, + private _status: S, + _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus, + ) { + // update the providers status with events + _provider.events?.addHandler(AllProviderEvents.Ready, () => { + // These casts are due to the face we don't "know" what status enum we are dealing with here (client or server). + // We could abstract this an implement it in the client/server libs to fix this, but the value is low. + this._status = _statusEnumType.READY as S; + }); + _provider.events?.addHandler(AllProviderEvents.Stale, () => { + this._status = _statusEnumType.STALE as S; + }); + _provider.events?.addHandler(AllProviderEvents.Error, (details) => { + if (details?.errorCode === ErrorCode.PROVIDER_FATAL) { + this._status = _statusEnumType.FATAL as S; + } else { + this._status = _statusEnumType.ERROR as S; + } + }); + } + + get provider(): P { + return this._provider; + } + + set provider(provider: P) { + this._provider = provider; + } + + get status(): S { + return this._status; + } + + set status(status: S) { + this._status = status; + } + + get allContextChangesSettled() { + return this._pendingContextChanges === 0; + } + + incrementPendingContextChanges() { + this._pendingContextChanges++; + } + + decrementPendingContextChanges() { + this._pendingContextChanges--; + } +} + +export abstract class OpenFeatureCommonAPI< + S extends AnyProviderStatus, + P extends CommonProvider = CommonProvider, + H extends BaseHook = BaseHook, +> + implements + Eventing, + EvaluationLifeCycle>, + ManageLogger> +{ + // accessor for the type of the ProviderStatus enum (client or server) + protected abstract readonly _statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus; + protected abstract _createEventEmitter(): GenericEventEmitter; + protected abstract _defaultProvider: ProviderWrapper; + protected abstract readonly _domainScopedProviders: Map>; + protected abstract readonly _apiEmitter: GenericEventEmitter; + + protected _hooks: H[] = []; + protected _context: EvaluationContext = {}; + protected _logger: Logger = new DefaultLogger(); + + private readonly _clientEventHandlers: Map = new Map(); + protected _domainScopedContext: Map = new Map(); + protected _clientEvents: Map> = new Map(); + protected _runsOn: Paradigm; + + constructor(category: Paradigm) { + this._runsOn = category; + } + + addHooks(...hooks: H[]): this { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + + getHooks(): H[] { + return this._hooks; + } + + clearHooks(): this { + this._hooks = []; + return this; + } + + setLogger(logger: Logger): this { + this._logger = new SafeLogger(logger); + return this; + } + + /** + * Get metadata about the default provider. + * @returns {ProviderMetadata} Provider Metadata + */ + get providerMetadata(): ProviderMetadata { + return this.getProviderMetadata(); + } + + /** + * Get metadata about a registered provider using the client name. + * An unbound or empty client name will return metadata from the default provider. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {ProviderMetadata} Provider Metadata + */ + getProviderMetadata(domain?: string): ProviderMetadata { + return this.getProviderForClient(domain).metadata; + } + + /** + * Adds a handler for the given provider event type. + * The handlers are called in the order they have been added. + * API (global) events run for all providers. + * @param {AnyProviderEvent} eventType The provider event type to listen to + * @param {EventHandler} handler The handler to run on occurrence of the event type + * @param {EventOptions} options Optional options such as signal for aborting + */ + addHandler(eventType: T, handler: EventHandler, options?: EventOptions): void { + [...new Map([[undefined, this._defaultProvider]]), ...this._domainScopedProviders].forEach((keyProviderTuple) => { + const domain = keyProviderTuple[0]; + const provider = keyProviderTuple[1].provider; + const status = keyProviderTuple[1].status; + const shouldRunNow = statusMatchesEvent(eventType, status); + + if (shouldRunNow) { + // run immediately, we're in the matching state + try { + handler({ domain, providerName: provider.metadata.name }); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + } + }); + + this._apiEmitter.addHandler(eventType, handler); + if (options?.signal && typeof options.signal.addEventListener === 'function') { + options.signal.addEventListener('abort', () => { + this.removeHandler(eventType, handler); + }); + } + } + + /** + * Removes a handler for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to remove the listener for + * @param {EventHandler} handler The handler to remove for the provider event type + */ + removeHandler(eventType: T, handler: EventHandler): void { + this._apiEmitter.removeHandler(eventType, handler); + } + + /** + * Removes all event handlers. + */ + clearHandlers(): void { + this._apiEmitter.removeAllHandlers(); + } + + /** + * Gets the current handlers for the given provider event type. + * @param {AnyProviderEvent} eventType The provider event type to get the current handlers for + * @returns {EventHandler[]} The handlers currently attached to the given provider event type + */ + getHandlers(eventType: T): EventHandler[] { + return this._apiEmitter.getHandlers(eventType); + } + + abstract setProviderAndWait( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Promise; + + abstract setProvider( + clientOrProvider?: string | P, + providerContextOrUndefined?: P | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): this; + + protected setAwaitableProvider(domainOrProvider?: string | P, providerOrUndefined?: P): Promise | void { + const domain = stringOrUndefined(domainOrProvider); + const provider = objectOrUndefined

(domainOrProvider) ?? objectOrUndefined

(providerOrUndefined); + + if (!provider) { + this._logger.debug('No provider defined, ignoring setProvider call'); + return; + } + + const oldProvider = this.getProviderForClient(domain); + const providerName = provider.metadata.name; + + // ignore no-ops + if (oldProvider === provider) { + this._logger.debug('Provider is already set, ignoring setProvider call'); + return; + } + + if (!provider.runsOn) { + this._logger.debug(`Provider '${provider.metadata.name}' has not defined its intended use.`); + } else if (provider.runsOn !== this._runsOn) { + throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`); + } + + const emitters = this.getAssociatedEventEmitters(domain); + + let initializationPromise: Promise | void = undefined; + const wrappedProvider = new ProviderWrapper( + provider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); + + // initialize the provider if it implements "initialize" and it's not already registered + if (typeof provider.initialize === 'function' && !this.allProviders.includes(provider)) { + initializationPromise = provider + .initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context) + ?.then(() => { + wrappedProvider.status = this._statusEnumType.READY; + // fetch the most recent event emitters, some may have been added during init + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }) + ?.catch((error) => { + // if this is a fatal error, transition to FATAL status + if ((error as OpenFeatureError)?.code === ErrorCode.PROVIDER_FATAL) { + wrappedProvider.status = this._statusEnumType.FATAL; + } else { + wrappedProvider.status = this._statusEnumType.ERROR; + } + this.getAssociatedEventEmitters(domain).forEach((emitter) => { + emitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: error?.message, + }); + }); + this._apiEmitter?.emit(AllProviderEvents.Error, { + clientName: domain, + domain, + providerName, + message: error?.message, + }); + // rethrow after emitting error events, so that public methods can control error handling + throw error; + }); + } else { + wrappedProvider.status = this._statusEnumType.READY; + emitters.forEach((emitter) => { + emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + }); + this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName }); + } + + if (domain) { + this._domainScopedProviders.set(domain, wrappedProvider); + } else { + this._defaultProvider = wrappedProvider; + } + + this.transferListeners(oldProvider, provider, domain, emitters); + + // Do not close a provider that is bound to any client + if (!this.allProviders.includes(oldProvider)) { + oldProvider?.onClose?.()?.catch((err: Error | undefined) => { + this._logger.error(`error closing provider: ${err?.message}, ${err?.stack}`); + }); + } + + return initializationPromise; + } + + protected getProviderForClient(domain?: string): P { + if (!domain) { + return this._defaultProvider.provider; + } + + return this._domainScopedProviders.get(domain)?.provider ?? this._defaultProvider.provider; + } + + protected buildAndCacheEventEmitterForClient(domain?: string): GenericEventEmitter { + const emitter = this._clientEvents.get(domain); + + if (emitter) { + return emitter; + } + + // lazily add the event emitters + const newEmitter = this._createEventEmitter(); + this._clientEvents.set(domain, newEmitter); + + const clientProvider = this.getProviderForClient(domain); + Object.values(AllProviderEvents).forEach((eventType) => + clientProvider.events?.addHandler(eventType, async (details) => { + newEmitter.emit(eventType, { + ...details, + clientName: domain, + domain, + providerName: clientProvider.metadata.name, + }); + }), + ); + + return newEmitter; + } + + private getUnboundEmitters(): GenericEventEmitter[] { + const domainScopedProviders = [...this._domainScopedProviders.keys()]; + const eventEmitterNames = [...this._clientEvents.keys()].filter(isDefined); + const unboundEmitterNames = eventEmitterNames.filter((name) => !domainScopedProviders.includes(name)); + return [ + // all unbound, named emitters + ...unboundEmitterNames.map((name) => this._clientEvents.get(name)), + // the default emitter + this._clientEvents.get(undefined), + ].filter(isDefined); + } + + protected getAssociatedEventEmitters(domain: string | undefined) { + return domain ? [this.buildAndCacheEventEmitterForClient(domain)] : this.getUnboundEmitters(); + } + + private transferListeners( + oldProvider: P, + newProvider: P, + domain: string | undefined, + emitters: (GenericEventEmitter | undefined)[], + ) { + this._clientEventHandlers + .get(domain) + ?.forEach((eventHandler) => oldProvider.events?.removeHandler(...eventHandler)); + + // iterate over the event types + const newClientHandlers = Object.values(AllProviderEvents).map<[AllProviderEvents, EventHandler]>((eventType) => { + const handler = async (details?: EventDetails) => { + // on each event type, fire the associated handlers + emitters.forEach((emitter) => { + emitter?.emit(eventType, { ...details, clientName: domain, domain, providerName: newProvider.metadata.name }); + }); + this._apiEmitter.emit(eventType, { + ...details, + clientName: domain, + domain, + providerName: newProvider.metadata.name, + }); + }; + + return [eventType, handler]; + }); + + this._clientEventHandlers.set(domain, newClientHandlers); + newClientHandlers.forEach((eventHandler) => newProvider.events?.addHandler(...eventHandler)); + } + + async close(): Promise { + try { + await this?._defaultProvider.provider?.onClose?.(); + } catch (err) { + this.handleShutdownError(this._defaultProvider.provider, err); + } + + const wrappers = Array.from(this._domainScopedProviders); + + await Promise.all( + wrappers.map(async ([, wrapper]) => { + try { + await wrapper?.provider.onClose?.(); + } catch (err) { + this.handleShutdownError(wrapper?.provider, err); + } + }), + ); + } + + protected async clearProvidersAndSetDefault(defaultProvider: P): Promise { + try { + await this.close(); + } catch (err) { + this._logger.error('Unable to cleanly close providers. Resetting to the default configuration.'); + } finally { + this._domainScopedProviders.clear(); + this._defaultProvider = new ProviderWrapper( + defaultProvider, + this._statusEnumType.NOT_READY, + this._statusEnumType, + ); + } + } + + private get allProviders(): P[] { + return [ + ...[...this._domainScopedProviders.values()].map((wrappers) => wrappers.provider), + this._defaultProvider.provider, + ]; + } + + private handleShutdownError(provider: P, err: unknown) { + this._logger.error(`Error during shutdown of provider ${provider.metadata.name}: ${err}`); + this._logger.error((err as Error)?.stack); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/index.ts b/tests/fixtures/source-overlays/sources/core/src/provider/index.ts new file mode 100644 index 0000000000..952bd5e772 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/index.ts @@ -0,0 +1,5 @@ +export * from './provider'; +export * from './multi-provider/errors'; +export * from './multi-provider/status-tracker'; +export * from './multi-provider/types'; +export * from './multi-provider/strategies'; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts new file mode 100644 index 0000000000..25f5c53ca8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/errors.ts @@ -0,0 +1,57 @@ +import type { ErrorCode } from '../../evaluation'; +import { GeneralError, OpenFeatureError } from '../../errors'; +import type { RegisteredProvider } from './types'; + +export class ErrorWithCode extends OpenFeatureError { + constructor( + public code: ErrorCode, + message: string, + ) { + super(message); + } +} + +export class AggregateError extends GeneralError { + constructor( + message: string, + public originalErrors: { source: string; error: unknown }[], + ) { + super(message); + Object.setPrototypeOf(this, AggregateError.prototype); + this.name = 'AggregateError'; + } +} + +export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { + const errorsWithSource = providerErrors + .map(({ providerName, error }) => { + return { source: providerName, error }; + }) + .flat(); + + // log first error in the message for convenience, but include all errors in the error object for completeness + const firstError = errorsWithSource[0]; + const message = firstError + ? `Provider errors occurred: ${firstError.source}: ${firstError.error}` + : 'Provider errors occurred'; + + return new AggregateError(message, errorsWithSource); +}; + +export const throwAggregateErrorFromPromiseResults = ( + result: PromiseSettledResult[], + providerEntries: RegisteredProvider[], +) => { + const errors = result + .map((r, i) => { + if (r.status === 'rejected') { + return { error: r.reason, providerName: providerEntries[i].name }; + } + return null; + }) + .filter((val): val is { error: unknown; providerName: string } => Boolean(val)); + + if (errors.length) { + throw constructAggregateError(errors); + } +}; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts new file mode 100644 index 0000000000..ccd38a63ad --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/status-tracker.ts @@ -0,0 +1,113 @@ +import type { EventDetails, ProviderEventEmitter, AnyProviderEvent } from '../../events'; +import type { RegisteredProvider } from './types'; + +/** + * Interface for the provider events enum that StatusTracker expects. + * Must have at least Error, Stale, ConfigurationChanged, and Ready events. + * Reconciling is optional (web only). + */ +type ProviderEventsEnum = { + Error: T; + Stale: T; + ConfigurationChanged: T; + Ready: T; + Reconciling?: T; +}; + +/** + * Interface for the provider status enum that StatusTracker expects. + */ +type ProviderStatusEnum = { + NOT_READY: T; + READY: T; + ERROR: T; + STALE: T; + FATAL: T; + RECONCILING?: T; +}; + +/** + * Tracks each individual provider's status by listening to emitted events + * Maintains an overall "status" for the multi provider which represents the "most critical" status out of all providers + */ +export class StatusTracker< + TProviderEvents extends AnyProviderEvent, + TProviderStatus, + TProvider extends { events?: ProviderEventEmitter }, +> { + private readonly providerStatuses: Record = {}; + + constructor( + private events: ProviderEventEmitter, + private statusEnum: ProviderStatusEnum, + private eventEnum: ProviderEventsEnum, + ) {} + + wrapEventHandler(providerEntry: RegisteredProvider) { + const provider = providerEntry.provider; + provider.events?.addHandler(this.eventEnum.Error, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.ERROR, details); + }); + + provider.events?.addHandler(this.eventEnum.Stale, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.STALE, details); + }); + + provider.events?.addHandler(this.eventEnum.ConfigurationChanged, (details?: EventDetails) => { + this.events.emit(this.eventEnum.ConfigurationChanged, details); + }); + + provider.events?.addHandler(this.eventEnum.Ready, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, this.statusEnum.READY, details); + }); + + // Handle Reconciling event (web only - server doesn't have this) + const reconcilingEvent = this.eventEnum.Reconciling; + const reconcilingStatus = this.statusEnum.RECONCILING; + if (reconcilingEvent && reconcilingStatus) { + provider.events?.addHandler(reconcilingEvent, (details?: EventDetails) => { + this.changeProviderStatus(providerEntry.name, reconcilingStatus, details); + }); + } + } + + providerStatus(name: string) { + return this.providerStatuses[name]; + } + + private getStatusFromProviderStatuses() { + const statuses = Object.values(this.providerStatuses); + if (statuses.includes(this.statusEnum.FATAL)) { + return this.statusEnum.FATAL; + } else if (statuses.includes(this.statusEnum.NOT_READY)) { + return this.statusEnum.NOT_READY; + } else if (statuses.includes(this.statusEnum.ERROR)) { + return this.statusEnum.ERROR; + } else if (statuses.includes(this.statusEnum.STALE)) { + return this.statusEnum.STALE; + } else if (this.statusEnum.RECONCILING && statuses.includes(this.statusEnum.RECONCILING)) { + return this.statusEnum.RECONCILING; + } + return this.statusEnum.READY; + } + + private changeProviderStatus(name: string, status: TProviderStatus, details?: EventDetails) { + const currentStatus = this.getStatusFromProviderStatuses(); + this.providerStatuses[name] = status; + const newStatus = this.getStatusFromProviderStatuses(); + if (currentStatus !== newStatus) { + if (newStatus === this.statusEnum.FATAL || newStatus === this.statusEnum.ERROR) { + this.events.emit(this.eventEnum.Error, details); + } else if (newStatus === this.statusEnum.STALE) { + this.events.emit(this.eventEnum.Stale, details); + } else if (newStatus === this.statusEnum.READY) { + this.events.emit(this.eventEnum.Ready, details); + } else { + const reconcilingEvent = this.eventEnum.Reconciling; + if (reconcilingEvent && this.statusEnum.RECONCILING && newStatus === this.statusEnum.RECONCILING) { + this.events.emit(reconcilingEvent, details); + } + } + } + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts new file mode 100644 index 0000000000..d35094ddec --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/base-evaluation-strategy.ts @@ -0,0 +1,152 @@ +import type { ErrorCode, EvaluationContext, FlagValue, FlagValueType, ResolutionDetails } from '../../../evaluation'; +import type { OpenFeatureError } from '../../../errors'; +import { ErrorWithCode } from '../errors'; +import type { TrackingEventDetails } from '../../../tracking'; + +export type StrategyEvaluationContext = { + flagKey: string; + flagType: FlagValueType; +}; + +export type BaseStrategyProviderContext = { + provider: TProvider; + providerName: string; + providerStatus: TProviderStatus; +}; + +export type BaseStrategyPerProviderContext = StrategyEvaluationContext & + BaseStrategyProviderContext; + +type BaseProviderResolutionResultBase = { + provider: TProvider; + providerName: string; +}; + +export type BaseProviderResolutionSuccessResult< + T extends FlagValue, + _TProviderStatus, + TProvider, +> = BaseProviderResolutionResultBase & { + details: ResolutionDetails; +}; + +export type BaseProviderResolutionErrorResult<_TProviderStatus, TProvider> = + BaseProviderResolutionResultBase & { + thrownError: unknown; + }; + +export type BaseProviderResolutionResult = + | BaseProviderResolutionSuccessResult + | BaseProviderResolutionErrorResult; + +export type BaseFinalResult = { + details?: ResolutionDetails; + provider?: TProvider; + providerName?: string; + errors?: { + providerName: string; + error: unknown; + }[]; +}; + +/** + * Base evaluation strategy for multi-provider flag resolution. + * + * This class is intended to be extended by concrete strategies and is not + * directly usable on its own. Implementations must provide a + * `determineFinalResult` method that takes the per-provider results and + * determines the final flag resolution outcome. + * + * The base class also provides default implementations for + * `shouldEvaluateThisProvider` and `shouldEvaluateNextProvider` that can be + * used as-is or overridden by subclasses as needed. + */ +export abstract class BaseEvaluationStrategy { + public runMode: 'parallel' | 'sequential' = 'sequential'; + + constructor(protected statusEnum: Record) {} + + shouldEvaluateThisProvider( + strategyContext: BaseStrategyPerProviderContext, + _evalContext?: EvaluationContext, + ): boolean { + if ( + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL + ) { + return false; + } + return true; + } + + shouldEvaluateNextProvider( + _strategyContext?: BaseStrategyPerProviderContext, + _context?: EvaluationContext, + _result?: BaseProviderResolutionResult, + ): boolean { + return true; + } + + shouldTrackWithThisProvider( + strategyContext: BaseStrategyProviderContext, + _context?: EvaluationContext, + _trackingEventName?: string, + _trackingEventDetails?: TrackingEventDetails, + ): boolean { + if ( + strategyContext.providerStatus === this.statusEnum.NOT_READY || + strategyContext.providerStatus === this.statusEnum.FATAL + ) { + return false; + } + return true; + } + + abstract determineFinalResult( + strategyContext: StrategyEvaluationContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult; + + protected hasError( + resolution: BaseProviderResolutionResult, + ): resolution is + | BaseProviderResolutionErrorResult + | (BaseProviderResolutionSuccessResult & { + details: ResolutionDetails & { errorCode: ErrorCode }; + }) { + return 'thrownError' in resolution || !!resolution.details.errorCode; + } + + protected hasErrorWithCode( + resolution: BaseProviderResolutionResult, + code: ErrorCode, + ): boolean { + return 'thrownError' in resolution + ? (resolution.thrownError as OpenFeatureError)?.code === code + : resolution.details.errorCode === code; + } + + protected collectProviderErrors( + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const errors: BaseFinalResult['errors'] = []; + for (const resolution of resolutions) { + if ('thrownError' in resolution) { + errors.push({ providerName: resolution.providerName, error: resolution.thrownError }); + } else if (resolution.details.errorCode) { + errors.push({ + providerName: resolution.providerName, + error: new ErrorWithCode(resolution.details.errorCode, resolution.details.errorMessage ?? 'unknown error'), + }); + } + } + return { errors }; + } + + protected resolutionToFinalResult( + resolution: BaseProviderResolutionSuccessResult, + ) { + return { details: resolution.details, provider: resolution.provider, providerName: resolution.providerName }; + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts new file mode 100644 index 0000000000..8914713ca9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/comparison-strategy.ts @@ -0,0 +1,66 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseProviderResolutionSuccessResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import { GeneralError } from '../../../errors'; + +export class ComparisonStrategy extends BaseEvaluationStrategy { + override runMode = 'parallel' as const; + + constructor( + statusEnum: Record, + private fallbackProvider: TProvider, + private onMismatch?: (resolutions: BaseProviderResolutionResult[]) => void, + ) { + super(statusEnum); + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + let value: T | undefined; + let fallbackResolution: BaseProviderResolutionSuccessResult | undefined; + let finalResolution: BaseProviderResolutionSuccessResult | undefined; + let mismatch = false; + for (const [i, resolution] of resolutions.entries()) { + if (this.hasError(resolution)) { + return this.collectProviderErrors(resolutions); + } + if (resolution.provider === this.fallbackProvider) { + fallbackResolution = resolution; + } + if (i === 0) { + finalResolution = resolution; + } + if (typeof value !== 'undefined' && value !== resolution.details.value) { + mismatch = true; + } else { + value = resolution.details.value; + } + } + + if (!fallbackResolution) { + throw new GeneralError('Fallback provider not found in resolution results'); + } + + if (!finalResolution) { + throw new GeneralError('Final resolution not found in resolution results'); + } + + if (mismatch) { + this.onMismatch?.(resolutions); + return { + details: fallbackResolution.details, + provider: fallbackResolution.provider, + }; + } + + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts new file mode 100644 index 0000000000..d9f66f6b9f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-match-strategy.ts @@ -0,0 +1,36 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; +import { ErrorCode } from '../../../evaluation'; + +export class FirstMatchStrategy extends BaseEvaluationStrategy { + override shouldEvaluateNextProvider( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + result: BaseProviderResolutionResult, + ): boolean { + if (this.hasErrorWithCode(result, ErrorCode.FLAG_NOT_FOUND)) { + return true; + } + if (this.hasError(result)) { + return false; + } + return false; + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts new file mode 100644 index 0000000000..8c3c8c008b --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/first-successful-strategy.ts @@ -0,0 +1,32 @@ +import type { + BaseFinalResult, + BaseProviderResolutionResult, + BaseStrategyPerProviderContext, +} from './base-evaluation-strategy'; +import { BaseEvaluationStrategy } from './base-evaluation-strategy'; +import type { EvaluationContext, FlagValue } from '../../../evaluation'; + +export class FirstSuccessfulStrategy extends BaseEvaluationStrategy< + TProviderStatus, + TProvider +> { + override shouldEvaluateNextProvider( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + result: BaseProviderResolutionResult, + ): boolean { + return this.hasError(result); + } + + override determineFinalResult( + strategyContext: BaseStrategyPerProviderContext, + context: EvaluationContext, + resolutions: BaseProviderResolutionResult[], + ): BaseFinalResult { + const finalResolution = resolutions[resolutions.length - 1]; + if (this.hasError(finalResolution)) { + return this.collectProviderErrors(resolutions); + } + return this.resolutionToFinalResult(finalResolution); + } +} diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts new file mode 100644 index 0000000000..d67a767a48 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './base-evaluation-strategy'; +export { FirstMatchStrategy as BaseFirstMatchStrategy } from './first-match-strategy'; +export { FirstSuccessfulStrategy as BaseFirstSuccessfulStrategy } from './first-successful-strategy'; +export { ComparisonStrategy as BaseComparisonStrategy } from './comparison-strategy'; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts new file mode 100644 index 0000000000..96137f3e5e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/multi-provider/types.ts @@ -0,0 +1,6 @@ +export type ProviderEntryInput = { + provider: TProvider; + name?: string; +}; + +export type RegisteredProvider = Required>; diff --git a/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts b/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts new file mode 100644 index 0000000000..74ae8606f1 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/provider/provider.ts @@ -0,0 +1,137 @@ +import type { EvaluationContext } from '../evaluation'; +import type { AnyProviderEvent, ProviderEventEmitter } from '../events'; +import type { TrackingEventDetails } from '../tracking'; +import type { Metadata, Paradigm } from '../types'; + +// TODO: with TypeScript 5+, we can use computed string properties, +// so we can extract all of these into a shared set of string consts and use that in both enums +// for now we have duplicated them. + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +export enum ServerProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = 'NOT_READY', + + /** + * The provider is ready to resolve flags. + */ + READY = 'READY', + + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = 'ERROR', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = 'STALE', + + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = 'FATAL', +} + +/** + * The state of the provider. + * Note that the provider's state is handled by the SDK. + */ +export enum ClientProviderStatus { + /** + * The provider has not been initialized and cannot yet evaluate flags. + */ + NOT_READY = 'NOT_READY', + + /** + * The provider is ready to resolve flags. + */ + READY = 'READY', + + /** + * The provider is in an error state and unable to evaluate flags. + */ + ERROR = 'ERROR', + + /** + * The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + */ + STALE = 'STALE', + + /** + * The provider has entered an irrecoverable error state. + */ + FATAL = 'FATAL', + + /** + * The provider is reconciling its state with a context change. + */ + RECONCILING = 'RECONCILING', +} + +/** + * A type representing any possible ProviderStatus (server or client side). + * In most cases, you probably want to import `ProviderStatus` from the respective SDK. + */ +export { ClientProviderStatus as AllProviderStatus }; + +/** + * Static data about the provider. + */ +export interface ProviderMetadata extends Readonly { + readonly name: string; +} + +export interface CommonProvider { + readonly metadata: ProviderMetadata; + + /** + * Represents where the provider is intended to be run. If defined, + * the SDK will enforce that the defined paradigm at runtime. + */ + readonly runsOn?: Paradigm; + + // TODO: in the future we could make this a never to force provider to remove it. + /** + * @deprecated the SDK now maintains the provider's state; there's no need for providers to implement this field. + * Returns a representation of the current readiness of the provider. + * + * _Providers which do not implement this method are assumed to be ready immediately._ + */ + readonly status?: S; + + /** + * An event emitter for ProviderEvents. + * @see ProviderEvents + */ + events?: ProviderEventEmitter; + + /** + * A function used to shut down the provider. + * Called when this provider is replaced with a new one, or when the OpenFeature is shut down. + */ + onClose?(): Promise; + + /** + * A function used to setup the provider. + * Called by the SDK after the provider is set if the provider's status is NOT_READY. + * When the returned promise resolves, the SDK fires the ProviderEvents.Ready event. + * If the returned promise rejects, the SDK fires the ProviderEvents.Error event. + * Use this function to perform any context-dependent setup within the provider. + * @param context + */ + initialize?(context?: EvaluationContext): Promise; + + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName + * @param context + * @param trackingEventDetails + */ + track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts new file mode 100644 index 0000000000..4ec6b214b7 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/attributes.ts @@ -0,0 +1,88 @@ +/** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ +export const TelemetryAttribute = { + /** + * The lookup key of the feature flag. + * + * - type: `string` + * - requirement level: `required` + * - example: `logo-color` + */ + KEY: 'feature_flag.key', + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: `reason` is `error` + * - example: `flag_not_found` + */ + ERROR_CODE: 'error.type', + /** + * A message explaining the nature of an error occurring during flag evaluation. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `Flag not found` + */ + ERROR_MESSAGE: 'error.message', + /** + * A semantic identifier for an evaluated flag value. + * + * - type: `string` + * - requirement level: `conditionally required` + * - condition: variant is defined on the evaluation details + * - example: `blue`; `on`; `true` + */ + VARIANT: 'feature_flag.result.variant', + /** + * The evaluated value of the feature flag. + * + * - type: `undefined` + * - requirement level: `conditionally required` + * - example: `#ff0000`; `1`; `true` + */ + VALUE: 'feature_flag.result.value', + /** + * The unique identifier for the flag evaluation context. For example, the targeting key. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `5157782b-2203-4c80-a857-dbbd5e7761db` + */ + CONTEXT_ID: 'feature_flag.context.id', + /** + * The reason code which shows how a feature flag value was determined. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `targeting_match` + */ + REASON: 'feature_flag.result.reason', + /** + * Describes a class of error the operation ended with. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `flag_not_found` + */ + PROVIDER: 'feature_flag.provider.name', + /** + * The identifier of the flag set to which the feature flag belongs. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `proj-1`; `ab98sgs`; `service1/dev` + */ + FLAG_SET_ID: 'feature_flag.set.id', + /** + * The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + * + * - type: `string` + * - requirement level: `recommended` + * - example: `1.0.0`; `2021-01-01` + */ + VERSION: 'feature_flag.version', +} as const; diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts new file mode 100644 index 0000000000..ef67288071 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/evaluation-event.ts @@ -0,0 +1,87 @@ +import { ErrorCode, StandardResolutionReasons, type EvaluationDetails, type FlagValue } from '../evaluation'; +import type { HookContext } from '../hooks'; +import { TelemetryAttribute } from './attributes'; +import { TelemetryFlagMetadata } from './flag-metadata'; + +/** + * Attribute types for OpenTelemetry. + * @see https://github.com/open-telemetry/opentelemetry-js/blob/fbbce6e1c0de86e4c504b5788d876fae4d3bc254/api/src/common/Attributes.ts#L35 + */ +export declare type AttributeValue = string | number | boolean | string[] | number[] | boolean[]; + +type EvaluationEvent = { + /** + * The name of the feature flag evaluation event. + */ + name: string; + /** + * The attributes of an OpenTelemetry compliant event for flag evaluation. + * @experimental The attributes are subject to change. + * @see https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ + attributes: Record; +}; + +const FLAG_EVALUATION_EVENT_NAME = 'feature_flag.evaluation'; + +/** + * Returns an OpenTelemetry compliant event for flag evaluation. + * @param {HookContext} hookContext Contextual information about the flag evaluation + * @param {EvaluationDetails} evaluationDetails The details of the flag evaluation + * @returns {EvaluationEvent} An evaluation event object containing the event name and attributes + */ +export function createEvaluationEvent( + hookContext: Readonly>, + evaluationDetails: EvaluationDetails, +): EvaluationEvent { + const attributes: EvaluationEvent['attributes'] = { + [TelemetryAttribute.KEY]: hookContext.flagKey, + [TelemetryAttribute.PROVIDER]: hookContext.providerMetadata.name, + [TelemetryAttribute.REASON]: (evaluationDetails.reason ?? StandardResolutionReasons.UNKNOWN).toLowerCase(), + }; + + if (evaluationDetails.variant) { + attributes[TelemetryAttribute.VARIANT] = evaluationDetails.variant; + } + + if (evaluationDetails.value !== null) { + if (typeof evaluationDetails.value !== 'object') { + attributes[TelemetryAttribute.VALUE] = evaluationDetails.value; + } else { + try { + // Objects are not valid attribute values, so we convert them to a JSON string + attributes[TelemetryAttribute.VALUE] = JSON.stringify(evaluationDetails.value); + } catch { + // We ignore non serializable values + } + } + } + + const contextId = + evaluationDetails.flagMetadata[TelemetryFlagMetadata.CONTEXT_ID] ?? hookContext.context.targetingKey; + if (contextId) { + attributes[TelemetryAttribute.CONTEXT_ID] = contextId; + } + + const setId = evaluationDetails.flagMetadata[TelemetryFlagMetadata.FLAG_SET_ID]; + if (setId) { + attributes[TelemetryAttribute.FLAG_SET_ID] = setId; + } + + const version = evaluationDetails.flagMetadata[TelemetryFlagMetadata.VERSION]; + if (version) { + attributes[TelemetryAttribute.VERSION] = version; + } + + if (evaluationDetails.reason === StandardResolutionReasons.ERROR) { + attributes[TelemetryAttribute.ERROR_CODE] = (evaluationDetails.errorCode ?? ErrorCode.GENERAL).toLowerCase(); + if (evaluationDetails.errorMessage) { + attributes[TelemetryAttribute.ERROR_MESSAGE] = evaluationDetails.errorMessage; + } + } + + return { + name: FLAG_EVALUATION_EVENT_NAME, + attributes, + }; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts new file mode 100644 index 0000000000..db671ac2bc --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/flag-metadata.ts @@ -0,0 +1,20 @@ +/** + * Well-known flag metadata attributes for telemetry events. + * @see https://openfeature.dev/specification/appendix-d#flag-metadata + */ +export const TelemetryFlagMetadata = { + /** + * The context identifier returned in the flag metadata uniquely identifies + * the subject of the flag evaluation. If not available, the targeting key + * should be used. + */ + CONTEXT_ID: 'contextId', + /** + * A logical identifier for the flag set. + */ + FLAG_SET_ID: 'flagSetId', + /** + * A version string (format unspecified) for the flag or flag set. + */ + VERSION: 'version', +} as const; diff --git a/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts b/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts new file mode 100644 index 0000000000..2f1561707b --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/telemetry/index.ts @@ -0,0 +1,3 @@ +export * from './attributes'; +export * from './flag-metadata'; +export * from './evaluation-event'; diff --git a/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts b/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts new file mode 100644 index 0000000000..41426f0b30 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/tracking/index.ts @@ -0,0 +1 @@ +export * from './tracking-event'; diff --git a/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts b/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts new file mode 100644 index 0000000000..5818bd3e6d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/tracking/tracking-event.ts @@ -0,0 +1,13 @@ +import type { PrimitiveValue } from '../types'; + +export type TrackingEventValue = PrimitiveValue | Date | { [key: string]: TrackingEventValue } | TrackingEventValue[]; + +/** + * A container for arbitrary data that can relevant to tracking events. + */ +export type TrackingEventDetails = { + /** + * A numeric value associated with this event. + */ + value?: number; +} & Record; diff --git a/tests/fixtures/source-overlays/sources/core/src/type-guards.ts b/tests/fixtures/source-overlays/sources/core/src/type-guards.ts new file mode 100644 index 0000000000..5787ff9f0a --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/type-guards.ts @@ -0,0 +1,35 @@ +/** + * Checks whether the parameter is a string. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +/** + * Returns the parameter if it is a string, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {string|undefined} The parameter if it is a string, otherwise undefined + */ +export function stringOrUndefined(value: unknown): string | undefined { + return isString(value) ? value : undefined; +} + +/** + * Checks whether the parameter is an object. + * @param {unknown} value The value to check + * @returns {value is string} True if the value is an object + */ +export function isObject(value: unknown): value is T { + return typeof value === 'object'; +} + +/** + * Returns the parameter if it is an object, otherwise returns undefined. + * @param {unknown} value The value to check + * @returns {object|undefined} The parameter if it is an object, otherwise undefined + */ +export function objectOrUndefined(value: unknown): T | undefined { + return isObject(value) ? value : undefined; +} diff --git a/tests/fixtures/source-overlays/sources/core/src/types/index.ts b/tests/fixtures/source-overlays/sources/core/src/types/index.ts new file mode 100644 index 0000000000..6c96c078ae --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './metadata'; +export * from './paradigm'; +export * from './structure'; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts b/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts new file mode 100644 index 0000000000..1311758dca --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/metadata.ts @@ -0,0 +1 @@ +export type Metadata = Record; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts b/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts new file mode 100644 index 0000000000..91be161348 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/paradigm.ts @@ -0,0 +1,4 @@ +/** + * Defines where the library is intended to be run. + */ +export type Paradigm = 'server' | 'client'; diff --git a/tests/fixtures/source-overlays/sources/core/src/types/structure.ts b/tests/fixtures/source-overlays/sources/core/src/types/structure.ts new file mode 100644 index 0000000000..7edbdd9370 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/core/src/types/structure.ts @@ -0,0 +1,7 @@ +export type PrimitiveValue = null | boolean | string | number; +export type JsonObject = { [key: string]: JsonValue }; +export type JsonArray = JsonValue[]; +/** + * Represents a JSON node value. + */ +export type JsonValue = PrimitiveValue | JsonObject | JsonArray; diff --git a/tests/fixtures/source-overlays/sources/mitt/LICENSE b/tests/fixtures/source-overlays/sources/mitt/LICENSE new file mode 100644 index 0000000000..84c52fe2cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/mitt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tests/fixtures/source-overlays/sources/mitt/src/index.ts b/tests/fixtures/source-overlays/sources/mitt/src/index.ts new file mode 100644 index 0000000000..17672aa176 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/mitt/src/index.ts @@ -0,0 +1,123 @@ +export type EventType = string | symbol; + +// An event handler can take an optional event argument +// and should not return a value +export type Handler = (event: T) => void; +export type WildcardHandler> = ( + type: keyof T, + event: T[keyof T] +) => void; + +// An array of all currently registered event handlers for a type +export type EventHandlerList = Array>; +export type WildCardEventHandlerList> = Array< + WildcardHandler +>; + +// A map of event types and their corresponding event handlers. +export type EventHandlerMap> = Map< + keyof Events | '*', + EventHandlerList | WildCardEventHandlerList +>; + +export interface Emitter> { + all: EventHandlerMap; + + on(type: Key, handler: Handler): void; + on(type: '*', handler: WildcardHandler): void; + + off( + type: Key, + handler?: Handler + ): void; + off(type: '*', handler: WildcardHandler): void; + + emit(type: Key, event: Events[Key]): void; + emit( + type: undefined extends Events[Key] ? Key : never + ): void; +} + +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} + */ +export default function mitt>( + all?: EventHandlerMap +): Emitter { + type GenericEventHandler = + | Handler + | WildcardHandler; + all = all || new Map(); + + return { + /** + * A Map of event names to registered handler functions. + */ + all, + + /** + * Register an event handler for the given type. + * @param {string|symbol} type Type of event to listen for, or `'*'` for all events + * @param {Function} handler Function to call in response to given event + * @memberOf mitt + */ + on(type: Key, handler: GenericEventHandler) { + const handlers: Array | undefined = all!.get(type); + if (handlers) { + handlers.push(handler); + } else { + all!.set(type, [handler] as EventHandlerList); + } + }, + + /** + * Remove an event handler for the given type. + * If `handler` is omitted, all handlers of the given type are removed. + * @param {string|symbol} type Type of event to unregister `handler` from (`'*'` to remove a wildcard handler) + * @param {Function} [handler] Handler function to remove + * @memberOf mitt + */ + off(type: Key, handler?: GenericEventHandler) { + const handlers: Array | undefined = all!.get(type); + if (handlers) { + if (handler) { + handlers.splice(handlers.indexOf(handler) >>> 0, 1); + } else { + all!.set(type, []); + } + } + }, + + /** + * Invoke all handlers for the given type. + * If present, `'*'` handlers are invoked after type-matched handlers. + * + * Note: Manually firing '*' handlers is not supported. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler + * @memberOf mitt + */ + emit(type: Key, evt?: Events[Key]) { + let handlers = all!.get(type); + if (handlers) { + (handlers as EventHandlerList) + .slice() + .map((handler) => { + handler(evt!); + }); + } + + handlers = all!.get('*'); + if (handlers) { + (handlers as WildCardEventHandlerList) + .slice() + .map((handler) => { + handler(type, evt!); + }); + } + } + }; +} diff --git a/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE b/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE new file mode 100644 index 0000000000..983fbe8aec --- /dev/null +++ b/tests/fixtures/source-overlays/sources/path-to-regexp/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts b/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts new file mode 100644 index 0000000000..83d00ead4e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/path-to-regexp/src/index.ts @@ -0,0 +1,641 @@ +/** + * Tokenizer results. + */ +interface LexToken { + type: + | "OPEN" + | "CLOSE" + | "PATTERN" + | "NAME" + | "CHAR" + | "ESCAPED_CHAR" + | "MODIFIER" + | "END"; + index: number; + value: string; +} + +/** + * Tokenize input string. + */ +function lexer(str: string): LexToken[] { + const tokens: LexToken[] = []; + let i = 0; + + while (i < str.length) { + const char = str[i]; + + if (char === "*" || char === "+" || char === "?") { + tokens.push({ type: "MODIFIER", index: i, value: str[i++] }); + continue; + } + + if (char === "\\") { + tokens.push({ type: "ESCAPED_CHAR", index: i++, value: str[i++] }); + continue; + } + + if (char === "{") { + tokens.push({ type: "OPEN", index: i, value: str[i++] }); + continue; + } + + if (char === "}") { + tokens.push({ type: "CLOSE", index: i, value: str[i++] }); + continue; + } + + if (char === ":") { + let name = ""; + let j = i + 1; + + while (j < str.length) { + const code = str.charCodeAt(j); + + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95 + ) { + name += str[j++]; + continue; + } + + break; + } + + if (!name) throw new TypeError(`Missing parameter name at ${i}`); + + tokens.push({ type: "NAME", index: i, value: name }); + i = j; + continue; + } + + if (char === "(") { + let count = 1; + let pattern = ""; + let j = i + 1; + + if (str[j] === "?") { + throw new TypeError(`Pattern cannot start with "?" at ${j}`); + } + + while (j < str.length) { + if (str[j] === "\\") { + pattern += str[j++] + str[j++]; + continue; + } + + if (str[j] === ")") { + count--; + if (count === 0) { + j++; + break; + } + } else if (str[j] === "(") { + count++; + if (str[j + 1] !== "?") { + throw new TypeError(`Capturing groups are not allowed at ${j}`); + } + } + + pattern += str[j++]; + } + + if (count) throw new TypeError(`Unbalanced pattern at ${i}`); + if (!pattern) throw new TypeError(`Missing pattern at ${i}`); + + tokens.push({ type: "PATTERN", index: i, value: pattern }); + i = j; + continue; + } + + tokens.push({ type: "CHAR", index: i, value: str[i++] }); + } + + tokens.push({ type: "END", index: i, value: "" }); + + return tokens; +} + +export interface ParseOptions { + /** + * Set the default delimiter for repeat parameters. (default: `'/'`) + */ + delimiter?: string; + /** + * List of characters to automatically consider prefixes when parsing. + */ + prefixes?: string; +} + +/** + * Parse a string for the raw tokens. + */ +export function parse(str: string, options: ParseOptions = {}): Token[] { + const tokens = lexer(str); + const { prefixes = "./", delimiter = "/#?" } = options; + const result: Token[] = []; + let key = 0; + let i = 0; + let path = ""; + + const tryConsume = (type: LexToken["type"]): string | undefined => { + if (i < tokens.length && tokens[i].type === type) return tokens[i++].value; + }; + + const mustConsume = (type: LexToken["type"]): string => { + const value = tryConsume(type); + if (value !== undefined) return value; + const { type: nextType, index } = tokens[i]; + throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); + }; + + const consumeText = (): string => { + let result = ""; + let value: string | undefined; + while ((value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR"))) { + result += value; + } + return result; + }; + + const isSafe = (value: string): boolean => { + for (const char of delimiter) if (value.indexOf(char) > -1) return true; + return false; + }; + + const safePattern = (prefix: string) => { + const prev = result[result.length - 1]; + const prevText = prefix || (prev && typeof prev === "string" ? prev : ""); + + if (prev && !prevText) { + throw new TypeError( + `Must have text between two parameters, missing text after "${(prev as Key).name}"`, + ); + } + + if (!prevText || isSafe(prevText)) return `[^${escapeString(delimiter)}]+?`; + return `(?:(?!${escapeString(prevText)})[^${escapeString(delimiter)}])+?`; + }; + + while (i < tokens.length) { + const char = tryConsume("CHAR"); + const name = tryConsume("NAME"); + const pattern = tryConsume("PATTERN"); + + if (name || pattern) { + let prefix = char || ""; + + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ""; + } + + if (path) { + result.push(path); + path = ""; + } + + result.push({ + name: name || key++, + prefix, + suffix: "", + pattern: pattern || safePattern(prefix), + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + + const value = char || tryConsume("ESCAPED_CHAR"); + if (value) { + path += value; + continue; + } + + if (path) { + result.push(path); + path = ""; + } + + const open = tryConsume("OPEN"); + if (open) { + const prefix = consumeText(); + const name = tryConsume("NAME") || ""; + const pattern = tryConsume("PATTERN") || ""; + const suffix = consumeText(); + + mustConsume("CLOSE"); + + result.push({ + name: name || (pattern ? key++ : ""), + pattern: name && !pattern ? safePattern(prefix) : pattern, + prefix, + suffix, + modifier: tryConsume("MODIFIER") || "", + }); + continue; + } + + mustConsume("END"); + } + + return result; +} + +export interface TokensToFunctionOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * Function for encoding input strings for output. + */ + encode?: (value: string, token: Key) => string; + /** + * When `false` the function can produce an invalid (unmatched) path. (default: `true`) + */ + validate?: boolean; +} + +/** + * Compile a string to a template function for the path. + */ +export function compile

( + str: string, + options?: ParseOptions & TokensToFunctionOptions, +) { + return tokensToFunction

(parse(str, options), options); +} + +export type PathFunction

= (data?: P) => string; + +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction

( + tokens: Token[], + options: TokensToFunctionOptions = {}, +): PathFunction

{ + const reFlags = flags(options); + const { encode = (x: string) => x, validate = true } = options; + + // Compile all the tokens into regexps. + const matches = tokens.map((token) => { + if (typeof token === "object") { + return new RegExp(`^(?:${token.pattern})$`, reFlags); + } + }); + + return (data: Record | null | undefined) => { + let path = ""; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (typeof token === "string") { + path += token; + continue; + } + + const value = data ? data[token.name] : undefined; + const optional = token.modifier === "?" || token.modifier === "*"; + const repeat = token.modifier === "*" || token.modifier === "+"; + + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError( + `Expected "${token.name}" to not repeat, but got an array`, + ); + } + + if (value.length === 0) { + if (optional) continue; + + throw new TypeError(`Expected "${token.name}" to not be empty`); + } + + for (let j = 0; j < value.length; j++) { + const segment = encode(value[j], token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError( + `Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`, + ); + } + + path += token.prefix + segment + token.suffix; + } + + continue; + } + + if (typeof value === "string" || typeof value === "number") { + const segment = encode(String(value), token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError( + `Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`, + ); + } + + path += token.prefix + segment + token.suffix; + continue; + } + + if (optional) continue; + + const typeOfMessage = repeat ? "an array" : "a string"; + throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`); + } + + return path; + }; +} + +export interface RegexpToFunctionOptions { + /** + * Function for decoding strings for params. + */ + decode?: (value: string, token: Key) => string; +} + +/** + * A match result contains data about the path match. + */ +export interface MatchResult

{ + path: string; + index: number; + params: P; +} + +/** + * A match is either `false` (no match) or a match result. + */ +export type Match

= false | MatchResult

; + +/** + * The match function takes a string and returns whether it matched the path. + */ +export type MatchFunction

= ( + path: string, +) => Match

; + +/** + * Create path match function from `path-to-regexp` spec. + */ +export function match

( + str: Path, + options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions, +) { + const keys: Key[] = []; + const re = pathToRegexp(str, keys, options); + return regexpToFunction

(re, keys, options); +} + +/** + * Create a path match function from `path-to-regexp` output. + */ +export function regexpToFunction

( + re: RegExp, + keys: Key[], + options: RegexpToFunctionOptions = {}, +): MatchFunction

{ + const { decode = (x: string) => x } = options; + + return function (pathname: string) { + const m = re.exec(pathname); + if (!m) return false; + + const { 0: path, index } = m; + const params = Object.create(null); + + for (let i = 1; i < m.length; i++) { + if (m[i] === undefined) continue; + + const key = keys[i - 1]; + + if (key.modifier === "*" || key.modifier === "+") { + params[key.name] = m[i].split(key.prefix + key.suffix).map((value) => { + return decode(value, key); + }); + } else { + params[key.name] = decode(m[i], key); + } + } + + return { path, index, params }; + }; +} + +/** + * Escape a regular expression string. + */ +function escapeString(str: string) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1"); +} + +/** + * Get the flags for a regexp from the options. + */ +function flags(options?: { sensitive?: boolean }) { + return options && options.sensitive ? "" : "i"; +} + +/** + * Metadata about a key. + */ +export interface Key { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +} + +/** + * A token is a string (nothing special) or key metadata (capture group). + */ +export type Token = string | Key; + +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp { + if (!keys) return path; + + const groupsRegex = /\((?:\?<(.*?)>)?(?!\?)/g; + + let index = 0; + let execResult = groupsRegex.exec(path.source); + while (execResult) { + keys.push({ + // Use parenthesized substring match if available, index otherwise + name: execResult[1] || index++, + prefix: "", + suffix: "", + modifier: "", + pattern: "", + }); + execResult = groupsRegex.exec(path.source); + } + + return path; +} + +/** + * Transform an array into a regexp. + */ +function arrayToRegexp( + paths: Array, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +): RegExp { + const parts = paths.map((path) => pathToRegexp(path, keys, options).source); + return new RegExp(`(?:${parts.join("|")})`, flags(options)); +} + +/** + * Create a path regexp from string input. + */ +function stringToRegexp( + path: string, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +) { + return tokensToRegexp(parse(path, options), keys, options); +} + +export interface TokensToRegexpOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * When `true` the regexp won't allow an optional trailing delimiter to match. (default: `false`) + */ + strict?: boolean; + /** + * When `true` the regexp will match to the end of the string. (default: `true`) + */ + end?: boolean; + /** + * When `true` the regexp will match from the beginning of the string. (default: `true`) + */ + start?: boolean; + /** + * Sets the final character for non-ending optimistic matches. (default: `/`) + */ + delimiter?: string; + /** + * List of characters that can also be "end" characters. + */ + endsWith?: string; + /** + * Encode path tokens for use in the `RegExp`. + */ + encode?: (value: string) => string; +} + +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export function tokensToRegexp( + tokens: Token[], + keys?: Key[], + options: TokensToRegexpOptions = {}, +) { + const { + strict = false, + start = true, + end = true, + encode = (x: string) => x, + delimiter = "/#?", + endsWith = "", + } = options; + const endsWithRe = `[${escapeString(endsWith)}]|$`; + const delimiterRe = `[${escapeString(delimiter)}]`; + let route = start ? "^" : ""; + + // Iterate over the tokens and create our regexp string. + for (const token of tokens) { + if (typeof token === "string") { + route += escapeString(encode(token)); + } else { + const prefix = escapeString(encode(token.prefix)); + const suffix = escapeString(encode(token.suffix)); + + if (token.pattern) { + if (keys) keys.push(token); + + if (prefix || suffix) { + if (token.modifier === "+" || token.modifier === "*") { + const mod = token.modifier === "*" ? "?" : ""; + route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; + } else { + route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; + } + } else { + if (token.modifier === "+" || token.modifier === "*") { + throw new TypeError( + `Can not repeat "${token.name}" without a prefix and suffix`, + ); + } + + route += `(${token.pattern})${token.modifier}`; + } + } else { + route += `(?:${prefix}${suffix})${token.modifier}`; + } + } + } + + if (end) { + if (!strict) route += `${delimiterRe}?`; + + route += !options.endsWith ? "$" : `(?=${endsWithRe})`; + } else { + const endToken = tokens[tokens.length - 1]; + const isEndDelimited = + typeof endToken === "string" + ? delimiterRe.indexOf(endToken[endToken.length - 1]) > -1 + : endToken === undefined; + + if (!strict) { + route += `(?:${delimiterRe}(?=${endsWithRe}))?`; + } + + if (!isEndDelimited) { + route += `(?=${delimiterRe}|${endsWithRe})`; + } + } + + return new RegExp(route, flags(options)); +} + +/** + * Supported `path-to-regexp` input types. + */ +export type Path = string | RegExp | Array; + +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export function pathToRegexp( + path: Path, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +) { + if (path instanceof RegExp) return regexpToRegexp(path, keys); + if (Array.isArray(path)) return arrayToRegexp(path, keys, options); + return stringToRegexp(path, keys, options); +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/LICENSE b/tests/fixtures/source-overlays/sources/server-sdk/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts new file mode 100644 index 0000000000..0446246bc9 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/client.ts @@ -0,0 +1,25 @@ +import type { + ClientMetadata, + EvaluationLifeCycle, + Eventing, + ManageContext, + ManageLogger, +} from '@openfeature/core'; +import type { Features } from '../evaluation'; +import type { ProviderStatus } from '../provider'; +import type { ProviderEvents } from '../events'; +import type { Tracking } from '../tracking'; + +export interface Client + extends EvaluationLifeCycle, + Features, + ManageContext, + ManageLogger, + Tracking, + Eventing { + readonly metadata: ClientMetadata; + /** + * Returns the status of the associated provider. + */ + readonly providerStatus: ProviderStatus; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts new file mode 100644 index 0000000000..4f1cce44fa --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts new file mode 100644 index 0000000000..4f440e9401 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/client/internal/open-feature-client.ts @@ -0,0 +1,436 @@ +import type { + ClientMetadata, + EvaluationContext, + EvaluationDetails, + EventHandler, + FlagValue, + FlagValueType, + HookContext, + JsonValue, + Logger, + TrackingEventDetails, + OpenFeatureError, + FlagMetadata, + ResolutionDetails, + EventOptions, +} from '@openfeature/core'; +import { + ErrorCode, + ProviderFatalError, + ProviderNotReadyError, + SafeLogger, + StandardResolutionReasons, + instantiateErrorByErrorCode, + statusMatchesEvent, +} from '@openfeature/core'; +import type { FlagEvaluationOptions } from '../../evaluation'; +import type { ProviderEvents } from '../../events'; +import type { InternalEventEmitter } from '../../events/internal/internal-event-emitter'; +import type { Hook } from '../../hooks'; +import type { Provider } from '../../provider'; +import { ProviderStatus } from '../../provider'; +import type { Client } from './../client'; + +type OpenFeatureClientOptions = { + /** + * @deprecated Use `domain` instead. + */ + name?: string; + domain?: string; + version?: string; +}; + +/** + * This implementation of the {@link Client} is meant to only be instantiated by the SDK. + * It should not be used outside the SDK and so should not be exported. + * @internal + */ +export class OpenFeatureClient implements Client { + private _context: EvaluationContext; + private _hooks: Hook[] = []; + private _clientLogger?: Logger; + + constructor( + // we always want the client to use the current provider, + // so pass a function to always access the currently registered one. + private readonly providerAccessor: () => Provider, + private readonly providerStatusAccessor: () => ProviderStatus, + private readonly emitterAccessor: () => InternalEventEmitter, + private readonly apiContextAccessor: () => EvaluationContext, + private readonly apiHooksAccessor: () => Hook[], + private readonly transactionContextAccessor: () => EvaluationContext, + private readonly globalLogger: () => Logger, + private readonly options: OpenFeatureClientOptions, + context: EvaluationContext = {}, + ) { + this._context = context; + } + + get metadata(): ClientMetadata { + return { + // Use domain if name is not provided + name: this.options.domain ?? this.options.name, + domain: this.options.domain ?? this.options.name, + version: this.options.version, + providerMetadata: this.providerAccessor().metadata, + }; + } + + get providerStatus(): ProviderStatus { + return this.providerStatusAccessor(); + } + + addHandler(eventType: ProviderEvents, handler: EventHandler, options?: EventOptions): void { + this.emitterAccessor().addHandler(eventType, handler); + const shouldRunNow = statusMatchesEvent(eventType, this._providerStatus); + + if (shouldRunNow) { + // run immediately, we're in the matching state + try { + handler({ + clientName: this.metadata.name, + domain: this.metadata.domain, + providerName: this._provider.metadata.name, + }); + } catch (err) { + this._logger?.error('Error running event handler:', err); + } + } + + if (options?.signal && typeof options.signal.addEventListener === 'function') { + options.signal.addEventListener('abort', () => { + this.removeHandler(eventType, handler); + }); + } + } + + removeHandler(eventType: ProviderEvents, handler: EventHandler) { + this.emitterAccessor().removeHandler(eventType, handler); + } + + getHandlers(eventType: ProviderEvents) { + return this.emitterAccessor().getHandlers(eventType); + } + + setLogger(logger: Logger): OpenFeatureClient { + this._clientLogger = new SafeLogger(logger); + return this; + } + + setContext(context: EvaluationContext): OpenFeatureClient { + this._context = context; + return this; + } + + getContext(): EvaluationContext { + return this._context; + } + + addHooks(...hooks: Hook[]): OpenFeatureClient { + this._hooks = [...this._hooks, ...hooks]; + return this; + } + + getHooks(): Hook[] { + return this._hooks; + } + + clearHooks(): OpenFeatureClient { + this._hooks = []; + return this; + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getBooleanDetails(flagKey, defaultValue, context, options)).value; + } + + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + this._provider.resolveBooleanEvaluation, + defaultValue, + 'boolean', + context, + options, + ); + } + + async getStringValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getStringDetails(flagKey, defaultValue, context, options)).value; + } + + getStringDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + // this isolates providers from our restricted string generic argument. + this._provider.resolveStringEvaluation as () => Promise>, + defaultValue, + 'string', + context, + options, + ); + } + + async getNumberValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getNumberDetails(flagKey, defaultValue, context, options)).value; + } + + getNumberDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate( + flagKey, + // this isolates providers from our restricted number generic argument. + this._provider.resolveNumberEvaluation as () => Promise>, + defaultValue, + 'number', + context, + options, + ); + } + + async getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise { + return (await this.getObjectDetails(flagKey, defaultValue, context, options)).value; + } + + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise> { + return this.evaluate(flagKey, this._provider.resolveObjectEvaluation, defaultValue, 'object', context, options); + } + + track(occurrenceKey: string, context: EvaluationContext = {}, occurrenceDetails: TrackingEventDetails = {}): void { + try { + this.shortCircuitIfNotReady(); + + if (typeof this._provider.track === 'function') { + // freeze the merged context + const frozenContext = Object.freeze(this.mergeContexts(context)); + return this._provider.track?.(occurrenceKey, frozenContext, occurrenceDetails); + } else { + this._logger.debug('Provider does not support the track function; will no-op.'); + } + } catch (err) { + this._logger.debug('Error recording tracking event.', err); + } + } + + private async evaluate( + flagKey: string, + resolver: ( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ) => Promise>, + defaultValue: T, + flagType: FlagValueType, + invocationContext: EvaluationContext = {}, + options: FlagEvaluationOptions = {}, + ): Promise> { + // merge global, client, and evaluation context + + const allHooks = [ + ...this.apiHooksAccessor(), + ...this.getHooks(), + ...(options.hooks || []), + ...(this._provider.hooks || []), + ]; + const allHooksReversed = [...allHooks].reverse(); + + const mergedContext = this.mergeContexts(invocationContext); + + // this reference cannot change during the course of evaluation + // it may be used as a key in WeakMaps + const hookContext: Readonly = { + flagKey, + defaultValue, + flagValueType: flagType, + clientMetadata: this.metadata, + providerMetadata: this._provider.metadata, + context: mergedContext, + logger: this._logger, + }; + + let evaluationDetails: EvaluationDetails; + + try { + const frozenContext = await this.beforeHooks(allHooks, hookContext, options); + + this.shortCircuitIfNotReady(); + + // run the referenced resolver, binding the provider. + const resolution = await resolver.call(this._provider, flagKey, defaultValue, frozenContext, this._logger); + + const resolutionDetails = { + ...resolution, + flagMetadata: Object.freeze(resolution.flagMetadata ?? {}), + flagKey, + }; + + if (resolutionDetails.errorCode) { + const err = instantiateErrorByErrorCode(resolutionDetails.errorCode, resolutionDetails.errorMessage); + await this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err, resolutionDetails.flagMetadata); + } else { + await this.afterHooks(allHooksReversed, hookContext, resolutionDetails, options); + evaluationDetails = resolutionDetails; + } + } catch (err: unknown) { + await this.errorHooks(allHooksReversed, hookContext, err, options); + evaluationDetails = this.getErrorEvaluationDetails(flagKey, defaultValue, err); + } + + await this.finallyHooks(allHooksReversed, hookContext, evaluationDetails, options); + return evaluationDetails; + } + + private async beforeHooks(hooks: Hook[], hookContext: HookContext, options: FlagEvaluationOptions) { + for (const hook of hooks) { + // freeze the hookContext + Object.freeze(hookContext); + + // use Object.assign to avoid modification of frozen hookContext + Object.assign(hookContext.context, { + ...hookContext.context, + ...(await hook?.before?.(hookContext, Object.freeze(options.hookHints))), + }); + } + + // after before hooks, freeze the EvaluationContext. + return Object.freeze(hookContext.context); + } + + private async afterHooks( + hooks: Hook[], + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + options: FlagEvaluationOptions, + ) { + // run "after" hooks sequentially + for (const hook of hooks) { + await hook?.after?.(hookContext, evaluationDetails, options.hookHints); + } + } + + private async errorHooks(hooks: Hook[], hookContext: HookContext, err: unknown, options: FlagEvaluationOptions) { + // run "error" hooks sequentially + for (const hook of hooks) { + try { + await hook?.error?.(hookContext, err, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'error' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error((err as Error)?.stack); + } + } + } + + private async finallyHooks( + hooks: Hook[], + hookContext: HookContext, + evaluationDetails: EvaluationDetails, + options: FlagEvaluationOptions, + ) { + // run "finally" hooks sequentially + for (const hook of hooks) { + try { + await hook?.finally?.(hookContext, evaluationDetails, options.hookHints); + } catch (err) { + this._logger.error(`Unhandled error during 'finally' hook: ${err}`); + if (err instanceof Error) { + this._logger.error(err.stack); + } + this._logger.error((err as Error)?.stack); + } + } + } + + private get _provider(): Provider { + return this.providerAccessor(); + } + + private get _providerStatus(): ProviderStatus { + return this.providerStatusAccessor(); + } + + private get _logger() { + return this._clientLogger || this.globalLogger(); + } + + private mergeContexts(invocationContext: EvaluationContext) { + // merge global and client contexts + return { + ...this.apiContextAccessor(), + ...this.transactionContextAccessor(), + ...this._context, + ...invocationContext, + }; + } + + private shortCircuitIfNotReady() { + // short circuit evaluation entirely if provider is in a bad state + if (this.providerStatus === ProviderStatus.NOT_READY) { + throw new ProviderNotReadyError('provider has not yet initialized'); + } else if (this.providerStatus === ProviderStatus.FATAL) { + throw new ProviderFatalError('provider is in an irrecoverable error state'); + } + } + + private getErrorEvaluationDetails( + flagKey: string, + defaultValue: T, + err: unknown, + flagMetadata: FlagMetadata = {}, + ): EvaluationDetails { + const errorMessage: string = (err as Error)?.message; + const errorCode: ErrorCode = (err as OpenFeatureError)?.code || ErrorCode.GENERAL; + + return { + errorCode, + errorMessage, + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + flagMetadata: Object.freeze(flagMetadata), + flagKey, + }; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts new file mode 100644 index 0000000000..b43c4f1268 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/evaluation.ts @@ -0,0 +1,177 @@ +import type { EvaluationContext, EvaluationDetails, HookHints, JsonValue } from '@openfeature/core'; +import type { Hook } from '../hooks'; + +export interface FlagEvaluationOptions { + hooks?: Hook[]; + hookHints?: HookHints; +} + +export interface Features { + /** + * Performs a flag evaluation that returns a boolean. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @param {boolean} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns a string. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getStringValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {string} T A optional generic argument constraining the string + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getStringDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns a number. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getNumberValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {number} T A optional generic argument constraining the number + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getNumberDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + /** + * Performs a flag evaluation that returns an object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise} Flag evaluation response + */ + getObjectValue( + flagKey: string, + defaultValue: JsonValue, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + getObjectValue( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise; + + /** + * Performs a flag evaluation that a returns an evaluation details object. + * @param {string} flagKey The flag key uniquely identifies a particular flag + * @template {JsonValue} T A optional generic argument describing the structure + * @param {T} defaultValue The value returned if an error occurs + * @param {EvaluationContext} context The evaluation context used on an individual flag evaluation + * @param {FlagEvaluationOptions} options Additional flag evaluation options + * @returns {Promise>} Flag evaluation details response + */ + getObjectDetails( + flagKey: string, + defaultValue: JsonValue, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; + + getObjectDetails( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + options?: FlagEvaluationOptions, + ): Promise>; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts new file mode 100644 index 0000000000..79c222c37d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/evaluation/index.ts @@ -0,0 +1 @@ +export * from './evaluation'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts new file mode 100644 index 0000000000..4d7205a517 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/events.ts @@ -0,0 +1,3 @@ +import { ServerProviderEvents } from '@openfeature/core'; + +export { ServerProviderEvents as ProviderEvents }; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts new file mode 100644 index 0000000000..6093005ef0 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/index.ts @@ -0,0 +1,2 @@ +export * from './open-feature-event-emitter'; +export * from './events'; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts new file mode 100644 index 0000000000..0a5c18c68d --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/internal/internal-event-emitter.ts @@ -0,0 +1,9 @@ +import { GenericEventEmitter } from '@openfeature/core'; +import type { ProviderEvents } from '../events'; + +/** + * The InternalEventEmitter is not exported publicly and should only be used within the SDK. It extends the + * OpenFeatureEventEmitter to include additional properties that can be included + * in the event details. + */ +export abstract class InternalEventEmitter extends GenericEventEmitter {}; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts new file mode 100644 index 0000000000..eea9172e59 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/events/open-feature-event-emitter.ts @@ -0,0 +1,21 @@ +import { GenericEventEmitter } from '@openfeature/core'; +import { EventEmitter } from 'node:events'; +import type { ProviderEvents } from './events'; + +/** + * The OpenFeatureEventEmitter can be used by provider developers to emit + * events at various parts of the provider lifecycle. + * + * NOTE: Ready and error events are automatically emitted by the SDK based on + * the result of the initialize method. + */ +export class OpenFeatureEventEmitter extends GenericEventEmitter { + protected readonly eventEmitter = new EventEmitter({ captureRejections: true }); + + constructor() { + super(); + this.eventEmitter.on('error', (err) => { + this._logger?.error('Error running event handler:', err); + }); + } +}; \ No newline at end of file diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts new file mode 100644 index 0000000000..c7fb077314 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/hook.ts @@ -0,0 +1,7 @@ +import type { BaseHook, EvaluationContext, FlagValue } from '@openfeature/core'; + +export type Hook = BaseHook< + FlagValue, + Promise | EvaluationContext | void, + Promise | void +>; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts new file mode 100644 index 0000000000..1146a6d5c3 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './hook'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts new file mode 100644 index 0000000000..249135cbf8 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/index.ts @@ -0,0 +1,9 @@ +export * from './client'; +export * from './provider'; +export * from './evaluation'; +export * from './open-feature'; +export * from './transaction-context'; +export * from './events'; +export * from './hooks'; +export * from './tracking'; +export * from '@openfeature/core'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts new file mode 100644 index 0000000000..3e818af05f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/open-feature.ts @@ -0,0 +1,277 @@ +import type { + EvaluationContext, + ManageContext, + ServerProviderStatus} from '@openfeature/core'; +import { + OpenFeatureCommonAPI, + ProviderWrapper, + objectOrUndefined, + stringOrUndefined, +} from '@openfeature/core'; +import type { Client } from './client'; +import { OpenFeatureClient } from './client/internal/open-feature-client'; +import { OpenFeatureEventEmitter } from './events'; +import type { Hook } from './hooks'; +import type { Provider} from './provider'; +import { NOOP_PROVIDER, ProviderStatus } from './provider'; +import type { + ManageTransactionContextPropagator, + TransactionContext, + TransactionContextPropagator} from './transaction-context'; +import { + NOOP_TRANSACTION_CONTEXT_PROPAGATOR +} from './transaction-context'; + +// use a symbol as a key for the global singleton +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/js-sdk/api'); + +type OpenFeatureGlobal = { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI; +}; +const _globalThis = globalThis as OpenFeatureGlobal; + +export class OpenFeatureAPI + extends OpenFeatureCommonAPI + implements + ManageContext, + ManageTransactionContextPropagator> +{ + protected _statusEnumType: typeof ProviderStatus = ProviderStatus; + protected _apiEmitter = new OpenFeatureEventEmitter(); + protected _defaultProvider: ProviderWrapper = new ProviderWrapper( + NOOP_PROVIDER, + ProviderStatus.NOT_READY, + this._statusEnumType, + ); + protected _domainScopedProviders: Map> = new Map(); + protected _createEventEmitter = () => new OpenFeatureEventEmitter(); + + private _transactionContextPropagator: TransactionContextPropagator = NOOP_TRANSACTION_CONTEXT_PROPAGATOR; + + private constructor() { + super('server'); + } + + /** + * Gets a singleton instance of the OpenFeature API. + * @ignore + * @returns {OpenFeatureAPI} OpenFeature API + */ + static getInstance(): OpenFeatureAPI { + const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; + if (globalApi) { + return globalApi; + } + + const instance = new OpenFeatureAPI(); + _globalThis[GLOBAL_OPENFEATURE_API_KEY] = instance; + return instance; + } + + private getProviderStatus(domain?: string): ProviderStatus { + if (!domain) { + return this._defaultProvider.status; + } + + return this._domainScopedProviders.get(domain)?.status ?? this._defaultProvider.status; + } + + /** + * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(provider: Provider): Promise; + /** + * Sets the provider that OpenFeature will use for flag evaluations on clients bound to the same domain. + * A promise is returned that resolves when the provider is ready. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {Promise} + * @throws Uncaught exceptions thrown by the provider during initialization. + */ + setProviderAndWait(domain: string, provider: Provider): Promise; + async setProviderAndWait(domainOrProvider?: string | Provider, providerOrUndefined?: Provider): Promise { + const domain = stringOrUndefined(domainOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(domainOrProvider); + + await this.setAwaitableProvider(domain, provider); + } + + /** + * Sets the default provider for flag evaluations. + * This provider will be used by domainless clients and clients associated with domains to which no provider is bound. + * Setting a provider supersedes the current provider used in new and existing unbound clients. + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(provider: Provider): this; + /** + * Sets the provider for flag evaluations of providers with the given name. + * Setting a provider supersedes the current provider used in new and existing clients bound to the same domain. + * @param {string} domain The name to identify the client + * @param {Provider} provider The provider responsible for flag evaluations. + * @returns {this} OpenFeature API + */ + setProvider(domain: string, provider: Provider): this; + setProvider(clientOrProvider?: string | Provider, providerOrUndefined?: Provider): this { + const domain = stringOrUndefined(clientOrProvider); + const provider = domain + ? objectOrUndefined(providerOrUndefined) + : objectOrUndefined(clientOrProvider); + + const maybePromise = this.setAwaitableProvider(domain, provider); + + // The setProvider method doesn't return a promise so we need to catch and + // log any errors that occur during provider initialization to avoid having + // an unhandled promise rejection. + Promise.resolve(maybePromise).catch((err) => { + this._logger.error('Error during provider initialization:', err); + }); + + return this; + } + + /** + * Get the default provider. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @returns {Provider} Default Provider + */ + getProvider(): Provider; + /** + * Get the provider bound to the specified domain. + * + * Note that it isn't recommended to interact with the provider directly, but rather through + * an OpenFeature client. + * @param {string} domain An identifier which logically binds clients with providers + * @returns {Provider} Domain-scoped provider + */ + getProvider(domain?: string): Provider; + getProvider(domain?: string): Provider { + return this.getProviderForClient(domain); + } + + setContext(context: EvaluationContext): this { + this._context = context; + return this; + } + + getContext(): EvaluationContext { + return this._context; + } + + /** + * A factory function for creating new domainless OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * All domainless or unbound clients use the default provider set via {@link this.setProvider setProvider}. + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, context?: EvaluationContext): Client; + /** + * A factory function for creating new domain scoped OpenFeature clients. + * Clients can contain their own state (e.g. logger, hook, context). + * Multiple clients can be used to segment feature flag configuration. + * + * If there is already a provider bound to this domain via {@link this.setProvider setProvider}, this provider will be used. + * Otherwise, the default provider is used until a provider is assigned to that domain. + * @param {string} domain An identifier which logically binds clients with providers + * @param {string} version The version of the client (only used for metadata) + * @param {EvaluationContext} context Evaluation context that should be set on the client to used during flag evaluations + * @returns {Client} OpenFeature Client + */ + getClient(domain: string, version: string, context?: EvaluationContext): Client; + getClient( + domainOrContext?: string | EvaluationContext, + versionOrContext?: string | EvaluationContext, + contextOrUndefined?: EvaluationContext, + ): Client { + const domain = stringOrUndefined(domainOrContext); + const version = stringOrUndefined(versionOrContext); + const context = + objectOrUndefined(domainOrContext) ?? + objectOrUndefined(versionOrContext) ?? + objectOrUndefined(contextOrUndefined); + + return new OpenFeatureClient( + () => this.getProviderForClient(domain), + () => this.getProviderStatus(domain), + () => this.buildAndCacheEventEmitterForClient(domain), + () => this.getContext(), + () => this.getHooks(), + () => this.getTransactionContext(), + () => this._logger, + { domain, version }, + context, + ); + } + + /** + * Clears all registered providers and resets the default provider. + * @returns {Promise} + */ + clearProviders(): Promise { + return super.clearProvidersAndSetDefault(NOOP_PROVIDER); + } + + setTransactionContextPropagator( + transactionContextPropagator: TransactionContextPropagator, + ): OpenFeatureCommonAPI { + const baseMessage = 'Invalid TransactionContextPropagator, will not be set: '; + if (typeof transactionContextPropagator?.getTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: getTransactionContext is not a function.`); + } else if (typeof transactionContextPropagator?.setTransactionContext !== 'function') { + this._logger.error(`${baseMessage}: setTransactionContext is not a function.`); + } else { + this._transactionContextPropagator = transactionContextPropagator; + } + return this; + } + + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + this._transactionContextPropagator.setTransactionContext(transactionContext, callback, ...args); + } + + getTransactionContext(): TransactionContext { + try { + return this._transactionContextPropagator.getTransactionContext(); + } catch (err: unknown) { + const error = err as Error | undefined; + this._logger.error(`Error getting transaction context: ${error?.message}, returning empty context.`); + this._logger.error(error?.stack); + return {}; + } + } +} + +/** + * A singleton instance of the OpenFeature API. + * @returns {OpenFeatureAPI} OpenFeature API + */ +export const OpenFeature = OpenFeatureAPI.getInstance(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts new file mode 100644 index 0000000000..05f507d444 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/flag-configuration.ts @@ -0,0 +1,36 @@ +/** + * Don't export types from this file publicly. + * It might cause confusion since these types are not a part of the general API, + * but just for the in-memory provider. + */ +import type { EvaluationContext, JsonValue } from '@openfeature/core'; + +type Variants = Record; + +/** + * A Feature Flag definition, containing it's specification + */ +export type Flag = { + /** + * An object containing all possible flags mappings (variant -> flag value) + */ + variants: Variants | Variants | Variants | Variants; + /** + * The variant it will resolve to in STATIC evaluation + */ + defaultVariant: string; + /** + * Determines if flag evaluation is enabled or not for this flag. + * If false, falls back to the default value provided to the client + */ + disabled: boolean; + /** + * Function used in order to evaluate a flag to a specific value given the provided context. + * It should return a variant key. + * If it does not return a valid variant it falls back to the default value provided to the client + * @param EvaluationContext + */ + contextEvaluator?: (ctx: EvaluationContext) => string; +}; + +export type FlagConfiguration = Record; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts new file mode 100644 index 0000000000..345c4aad78 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/in-memory-provider.ts @@ -0,0 +1,139 @@ +import type { + EvaluationContext, + FlagValueType, + JsonValue, + Logger, + ResolutionDetails} from '@openfeature/core'; +import { + FlagNotFoundError, + GeneralError, + OpenFeatureError, + StandardResolutionReasons, + TypeMismatchError +} from '@openfeature/core'; +import type { Provider } from '../provider'; +import type { Flag, FlagConfiguration } from './flag-configuration'; +import { VariantFoundError } from './variant-not-found-error'; +import { OpenFeatureEventEmitter, ProviderEvents } from '../..'; + +/** + * A simple OpenFeature provider intended for demos and as a test stub. + */ +export class InMemoryProvider implements Provider { + public readonly events = new OpenFeatureEventEmitter(); + public readonly runsOn = 'server'; + readonly metadata = { + name: 'in-memory', + } as const; + private _flagConfiguration: FlagConfiguration; + + constructor(flagConfiguration: FlagConfiguration = {}) { + this._flagConfiguration = { ...flagConfiguration }; + } + + /** + * Overwrites the configured flags. + * @param { FlagConfiguration } flagConfiguration new flag configuration + */ + putConfiguration(flagConfiguration: FlagConfiguration) { + const flagsChanged = Object.entries(flagConfiguration) + .filter(([key, value]) => this._flagConfiguration[key] !== value) + .map(([key]) => key); + + this._flagConfiguration = { ...flagConfiguration }; + this.events.emit(ProviderEvents.ConfigurationChanged, { flagsChanged }); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + async resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context?: EvaluationContext, + logger?: Logger, + ): Promise> { + return this.resolveFlagWithReason(flagKey, defaultValue, context, logger); + } + + private async resolveFlagWithReason( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): Promise> { + try { + const resolutionResult = this.lookupFlagValue(flagKey, defaultValue, ctx, logger); + + if (typeof resolutionResult?.value != typeof defaultValue) { + throw new TypeMismatchError(); + } + + return resolutionResult; + } catch (error: unknown) { + if (!(error instanceof OpenFeatureError)) { + throw new GeneralError((error as Error)?.message || 'unknown error'); + } + throw error; + } + } + + private lookupFlagValue( + flagKey: string, + defaultValue: T, + ctx?: EvaluationContext, + logger?: Logger, + ): ResolutionDetails { + if (!(flagKey in this._flagConfiguration)) { + const message = `no flag found with key ${flagKey}`; + logger?.debug(message); + throw new FlagNotFoundError(message); + } + const flagSpec: Flag = this._flagConfiguration[flagKey]; + + if (flagSpec.disabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DISABLED }; + } + + const isContextEval = ctx && flagSpec?.contextEvaluator; + const variant = isContextEval ? flagSpec.contextEvaluator?.(ctx) : flagSpec.defaultVariant; + + const value = variant && flagSpec?.variants[variant]; + + if (value === undefined) { + const message = `no value associated with variant ${variant}`; + logger?.error(message); + throw new VariantFoundError(message); + } + + return { + value: value as T, + ...(variant && { variant }), + reason: isContextEval ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.STATIC, + }; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts new file mode 100644 index 0000000000..1bf0d53764 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/index.ts @@ -0,0 +1 @@ +export * from './in-memory-provider'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts new file mode 100644 index 0000000000..3553a7c090 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/in-memory-provider/variant-not-found-error.ts @@ -0,0 +1,15 @@ +import { ErrorCode, OpenFeatureError } from '@openfeature/core'; + +/** + * A custom error for the in-memory provider. + * Indicates the resolved or default variant doesn't exist. + */ +export class VariantFoundError extends OpenFeatureError { + code: ErrorCode; + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, VariantFoundError.prototype); + this.name = 'VariantFoundError'; + this.code = ErrorCode.GENERAL; + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts new file mode 100644 index 0000000000..33e12fea3a --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/index.ts @@ -0,0 +1,3 @@ +export * from './provider'; +export * from './no-op-provider'; +export * from './in-memory-provider'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts new file mode 100644 index 0000000000..b7081c08cf --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/no-op-provider.ts @@ -0,0 +1,38 @@ +import type { JsonValue, ResolutionDetails } from '@openfeature/core'; +import type { Provider } from './provider'; + +const REASON_NO_OP = 'No-op'; + +/** + * The No-op provider is set by default, and simply always returns the default value. + */ +class NoopFeatureProvider implements Provider { + readonly metadata = { + name: 'No-op Provider', + } as const; + + resolveBooleanEvaluation(_: string, defaultValue: boolean): Promise> { + return this.noOp(defaultValue); + } + + resolveStringEvaluation(_: string, defaultValue: string): Promise> { + return this.noOp(defaultValue); + } + + resolveNumberEvaluation(_: string, defaultValue: number): Promise> { + return this.noOp(defaultValue); + } + + resolveObjectEvaluation(_: string, defaultValue: T): Promise> { + return this.noOp(defaultValue); + } + + private noOp(defaultValue: T) { + return Promise.resolve({ + value: defaultValue, + reason: REASON_NO_OP, + }); + } +} + +export const NOOP_PROVIDER = new NoopFeatureProvider(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts new file mode 100644 index 0000000000..4c97f17758 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/provider/provider.ts @@ -0,0 +1,68 @@ +import type { + CommonProvider, + EvaluationContext, + JsonValue, + Logger, + ResolutionDetails} from '@openfeature/core'; +import { + ServerProviderStatus, +} from '@openfeature/core'; +import type { Hook } from '../hooks'; + +export { ServerProviderStatus as ProviderStatus }; + +/** + * Interface that providers must implement to resolve flag values for their particular + * backend or vendor. + * + * Implementation for resolving all the required flag types must be defined. + */ +export interface Provider extends CommonProvider { + /** + * A provider hook exposes a mechanism for provider authors to register hooks + * to tap into various stages of the flag evaluation lifecycle. These hooks can + * be used to perform side effects and mutate the context for purposes of the + * provider. Provider hooks are not configured or controlled by the application author. + */ + readonly hooks?: Hook[]; + + /** + * Resolve a boolean flag and its evaluation details. + */ + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve a string flag and its evaluation details. + */ + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve a numeric flag and its evaluation details. + */ + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger, + ): Promise>; + + /** + * Resolve and parse an object flag and its evaluation details. + */ + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger, + ): Promise>; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts new file mode 100644 index 0000000000..5794a1d40f --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/index.ts @@ -0,0 +1 @@ +export * from './tracking'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts new file mode 100644 index 0000000000..b7ef17e9c7 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/tracking/tracking.ts @@ -0,0 +1,12 @@ +import type { EvaluationContext, TrackingEventDetails } from '@openfeature/core'; + +export interface Tracking { + + /** + * Track a user action or application state, usually representing a business objective or outcome. + * @param trackingEventName an identifier for the event + * @param context the evaluation context + * @param trackingEventDetails the details of the tracking event + */ + track(trackingEventName: string, context?: EvaluationContext, trackingEventDetails?: TrackingEventDetails): void; +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts new file mode 100644 index 0000000000..21cce7d664 --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/async-local-storage-transaction-context-propagator.ts @@ -0,0 +1,19 @@ +import type { EvaluationContext } from '@openfeature/core'; +import type { TransactionContext, TransactionContextPropagator } from './transaction-context'; +import { AsyncLocalStorage } from 'async_hooks'; + +export class AsyncLocalStorageTransactionContextPropagator implements TransactionContextPropagator { + private asyncLocalStorage = new AsyncLocalStorage(); + + getTransactionContext(): EvaluationContext { + return this.asyncLocalStorage.getStore() ?? {}; + } + + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + this.asyncLocalStorage.run(transactionContext, callback, ...args); + } +} diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts new file mode 100644 index 0000000000..34e976d33e --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/index.ts @@ -0,0 +1,3 @@ +export * from './transaction-context'; +export * from './no-op-transaction-context-propagator'; +export * from './async-local-storage-transaction-context-propagator'; diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts new file mode 100644 index 0000000000..ee0a176f5c --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/no-op-transaction-context-propagator.ts @@ -0,0 +1,18 @@ +import type { EvaluationContext } from '@openfeature/core'; +import type { TransactionContext, TransactionContextPropagator } from './transaction-context'; + +class NoopTransactionContextPropagator implements TransactionContextPropagator { + getTransactionContext(): EvaluationContext { + return {}; + } + + setTransactionContext( + _: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void { + callback(...args); + } +} + +export const NOOP_TRANSACTION_CONTEXT_PROPAGATOR = new NoopTransactionContextPropagator(); diff --git a/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts new file mode 100644 index 0000000000..f305a107eb --- /dev/null +++ b/tests/fixtures/source-overlays/sources/server-sdk/src/transaction-context/transaction-context.ts @@ -0,0 +1,63 @@ +import type { EvaluationContext } from '@openfeature/core'; + +/** + * Transaction context is a mechanism for adding transaction specific context that + * is merged with evaluation context prior to flag evaluation. Examples of potential + * transaction specific context include: a user id, user agent, or request path. + */ +export type TransactionContext = EvaluationContext; + +export interface ManageTransactionContextPropagator extends TransactionContextPropagator { + /** + * Sets a transaction context propagator on this receiver. The transaction context + * propagator is responsible for persisting context for the duration of a single + * transaction. + * @template T The type of the receiver + * @param {TransactionContextPropagator} transactionContextPropagator The context propagator to be used + * @returns {T} The receiver (this object) + */ + setTransactionContextPropagator(transactionContextPropagator: TransactionContextPropagator): T; +} + +export interface TransactionContextPropagator { + /** + * Returns the currently defined transaction context using the registered transaction + * context propagator. + * @returns {TransactionContext} The current transaction context + */ + getTransactionContext(): TransactionContext; + + /** + * Sets the transaction context using the registered transaction context propagator. + * Runs the {@link callback} function, in which the {@link transactionContext} will be available by calling + * {@link this#getTransactionContext}. + * + * The {@link TransactionContextPropagator} must persist the {@link transactionContext} and make it available + * to {@link callback} via {@link this#getTransactionContext}. + * + * The precedence of merging context can be seen in {@link https://openfeature.dev/specification/sections/evaluation-context#requirement-323 the specification}. + * + * Example: + * + * ```js + * app.use((req: Request, res: Response, next: NextFunction) => { + * const ip = res.headers.get("X-Forwarded-For") + * OpenFeature.setTransactionContext({ targetingKey: req.user.id, ipAddress: ip }, () => { + * // The transaction context is used in any flag evaluation throughout the whole call chain of next + * next(); + * }); + * }) + * + * ``` + * @template TArgs The optional args passed to the callback function + * @template R The return value of the callback + * @param {TransactionContext} transactionContext The transaction specific context + * @param {(...args: unknown[]) => R} callback Callback function to run + * @param {...unknown[]} args Optional arguments that are passed to the callback function + */ + setTransactionContext( + transactionContext: TransactionContext, + callback: (...args: TArgs) => R, + ...args: TArgs + ): void; +} diff --git a/tests/harness/source-overlays.test.ts b/tests/harness/source-overlays.test.ts new file mode 100644 index 0000000000..459a6cad51 --- /dev/null +++ b/tests/harness/source-overlays.test.ts @@ -0,0 +1,306 @@ +/* --sources end to end: a pnpm-installed project, upstream TypeScript for + * three of its packages, and a schema-1 manifest that this test writes the + * way scriptc-pm would (tests/fixtures/source-overlays holds the installed + * tree and the upstream sources at the published commits). Every build + * goes through the CLI so the flag, manifest discovery, fallback notes and + * the build cache are covered as users see them. */ +import { execFile } from "node:child_process"; +import { + appendFileSync, + cpSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const fixture = join(repoRoot, "tests/fixtures/source-overlays"); +const cli = join(repoRoot, "packages/cli/dist/main.js"); + +interface OverlaySpec { + name: string; + version: string; + /** pnpm's instance directory under node_modules/.pnpm. */ + instance: string; + /** Directory under fixtures/source-overlays/sources. */ + source: string; + repo: string; + commit: string; + subdir?: string; +} + +const OVERLAYS: OverlaySpec[] = [ + { + name: "path-to-regexp", + version: "6.3.0", + instance: "path-to-regexp@6.3.0", + source: "path-to-regexp", + repo: "github.com/pillarjs/path-to-regexp", + commit: "75a92c3d7c42159f459ab42f346899152906ea8c", + }, + { + name: "@openfeature/server-sdk", + version: "1.18.0", + instance: "@openfeature+server-sdk@1.18.0_@openfeature+core@1.9.2", + source: "server-sdk", + repo: "github.com/open-feature/js-sdk", + commit: "aff60f6776043bee3360053bbc76af5d95e907ae", + subdir: "packages/server", + }, + { + name: "@openfeature/core", + version: "1.9.2", + instance: "@openfeature+core@1.9.2", + source: "core", + repo: "github.com/open-feature/js-sdk", + commit: "2064d75ae097ff66f7d5e8073eb0abf711a5a3b8", + subdir: "packages/shared", + }, + { + name: "mitt", + version: "3.0.1", + instance: "mitt@3.0.1", + source: "mitt", + repo: "github.com/developit/mitt", + commit: "b240473b5707857ba2c6a8e6d707c28d1e39da49", + }, +]; + +/** Copies the installed project to a fresh directory outside every + * node_modules tree and materializes `.scriptc-pm/` like scriptc-pm sync: + * one overlay per instance (upstream source, a synthesized package.json, + * and a node_modules link to the instance's own pnpm dependency dir) and + * the manifest. */ +function materialize(root: string): string { + const project = join(root, "project"); + cpSync(join(fixture, "project"), project, { recursive: true, verbatimSymlinks: true }); + const instances = OVERLAYS.map((o) => { + const pkg = join(project, ".scriptc-pm/overlay", o.instance, "pkg"); + cpSync(join(fixture, "sources", o.source), pkg, { recursive: true }); + const entry = "./src/index.ts"; + const exports = { ".": { types: entry, import: entry, require: entry, default: entry } }; + writeFileSync( + join(pkg, "package.json"), + `${JSON.stringify({ name: o.name, version: o.version, type: "module", exports }, null, 2)}\n`, + ); + const pnpmDeps = join(project, "node_modules/.pnpm", o.instance, "node_modules"); + symlinkSync(pnpmDeps, join(pkg, "node_modules")); + return { + installedDir: join(pnpmDeps, o.name), + name: o.name, + version: o.version, + overlayDir: pkg, + exports, + publishedFormat: "dual", + source: { + repo: o.repo, + commit: o.commit, + ...(o.subdir !== undefined ? { subdir: o.subdir } : {}), + strategy: "fixture", + }, + verification: { identity: "fixture", content: "unverified", surface: "unverified" }, + }; + }); + writeFileSync( + join(project, ".scriptc-pm/manifest.json"), + `${JSON.stringify({ schema: 1, generator: "source-overlays.test", lockDigest: "sha256-fixture", instances, fallbacks: [] }, null, 2)}\n`, + ); + return project; +} + +interface Outcome { + code: number; + stdout: string; + stderr: string; +} + +async function run( + file: string, + args: string[] = [], + cwd?: string, + env?: NodeJS.ProcessEnv, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(file, args, { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + ...(cwd !== undefined ? { cwd } : {}), + ...(env !== undefined ? { env } : {}), + }); + return { code: 0, stdout, stderr }; + } catch (e) { + const err = e as { code?: number; stdout?: string; stderr?: string }; + return { code: err.code ?? 1, stdout: err.stdout ?? "", stderr: err.stderr ?? "" }; + } +} + +function scriptc(args: string[], cwd: string, cacheDir: string): Promise { + return run(process.execPath, [cli, ...args], cwd, { + ...process.env, + SCRIPTC_CACHE_DIR: cacheDir, + SCRIPTC_TIMING: "1", + }); +} + +const cacheHit = (stderr: string): boolean => stderr.includes('"phase":"executable-cache-hit"'); + +let root: string; +let project: string; +let cacheDir: string; + +beforeAll(() => { + root = mkdtempSync(join(realpathSync(tmpdir()), "scriptc-sources-")); + project = materialize(root); + // scriptc creates the cache root itself (private, mode 0700). + cacheDir = join(root, "cache"); +}); + +afterAll(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }); +}); + +async function node(program: string, cwd = project): Promise { + return run(process.execPath, [program], cwd); +} + +describe("--sources", () => { + test("path-to-regexp's parser compiles from source and matches Node; its published build does not", async () => { + const expected = await node("routes.ts"); + expect(expected.code).toBe(0); + + // Published JS: the ES5 dist's String#concat calls compile to runtime + // fences, so the binary diverges from Node. + const published = await scriptc(["build", "routes.ts", "-o", "routes-pub"], project, cacheDir); + expect(published.code).toBe(0); + expect((await run(join(project, "routes-pub"))).stderr).toContain("SC2020"); + + const first = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-src"], + project, + cacheDir, + ); + expect(first.stderr).not.toContain("source overlay dropped"); + expect(first.code).toBe(0); + expect(cacheHit(first.stderr)).toBe(false); + expect(await run(join(project, "routes-src"))).toEqual(expected); + + const second = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-src"], + project, + cacheDir, + ); + expect(second.code).toBe(0); + expect(cacheHit(second.stderr)).toBe(true); + + // The manifest (its lock digest) is part of the cache identity. + const manifest = JSON.parse( + readFileSync(join(project, ".scriptc-pm/manifest.json"), "utf8"), + ) as { lockDigest: string }; + manifest.lockDigest = "sha256-other"; + const rekeyed = join(root, "rekeyed-manifest.json"); + writeFileSync(rekeyed, JSON.stringify(manifest)); + const third = await scriptc( + ["build", "routes.ts", `--sources=${rekeyed}`, "-o", "routes-src"], + project, + cacheDir, + ); + expect(third.code).toBe(0); + expect(cacheHit(third.stderr)).toBe(false); + expect(await run(join(project, "routes-src"))).toEqual(expected); + }); + + test("coverage attributes overlay statements to their package", async () => { + const coverage = await scriptc(["coverage", "routes.ts", "--sources"], project, cacheDir); + expect(coverage.code).toBe(0); + expect(coverage.stdout).toMatch(/path-to-regexp@6\.3\.0\s+static from source/); + expect(coverage.stdout).toContain( + "path-to-regexp@6.3.0 github.com/pillarjs/path-to-regexp @ 75a92c3d7c42", + ); + expect(coverage.stdout).toMatch(/\d+ statements: \d+ static \S*\(100%\)/); + }); + + test("overlays that do not compile fall back to published JS, per package", async () => { + // @openfeature/server-sdk@1.18.0 was built against its monorepo's core + // sources at 1.8.0, so its source does not typecheck against the + // installed core 1.9.2; core's own source redeclares Error#cause, which + // lowering refuses. Both fall back, and the build is the published one. + const withSources = await scriptc( + ["build", "flags.ts", "--sources", "-o", "flags-src"], + project, + cacheDir, + ); + expect(withSources.code).toBe(0); + expect(withSources.stderr).toMatch( + /sources: @openfeature\/server-sdk@1\.18\.0 compiles from published JS \(source overlay dropped: SC0001 at src\/client\/internal\/open-feature-client\.ts:\d+/, + ); + expect(withSources.stderr).toMatch( + /sources: @openfeature\/core@1\.9\.2 compiles from published JS \(source overlay dropped: SC1090 at src\/errors\/open-feature-error-abstract\.ts:\d+/, + ); + const published = await scriptc(["build", "flags.ts", "-o", "flags-pub"], project, cacheDir); + expect(published.code).toBe(0); + expect(await run(join(project, "flags-src"))).toEqual(await run(join(project, "flags-pub"))); + }); + + test("a consumer that only fails against the source surface falls back", async () => { + // mitt's declared Emitter interface (generic overloads) does not lower + // in the program; its published (minified) JS types by inference and + // does. The failure is in the program, not the overlay, so the + // published-surface probe decides. + const expected = await node("emit.ts"); + const built = await scriptc( + ["build", "emit.ts", "--sources", "-o", "emit-src"], + project, + cacheDir, + ); + expect(built.code).toBe(0); + expect(built.stderr).toMatch( + /sources: mitt@3\.0\.1 compiles from published JS \(source overlay dropped: the program does not compile against its source surface \(SC2009/, + ); + expect(await run(join(project, "emit-src"))).toEqual(expected); + }); + + test("a broken overlay and a require() edge each fall back", async () => { + const broken = materialize(join(root, "broken")); + appendFileSync( + join(broken, ".scriptc-pm/overlay/path-to-regexp@6.3.0/pkg/src/index.ts"), + '\nexport const broken: number = "not a number";\n', + ); + const fallback = await scriptc( + ["build", "routes.ts", "--sources", "-o", "routes-fb"], + broken, + cacheDir, + ); + expect(fallback.code).toBe(0); + expect(fallback.stderr).toMatch( + /sources: path-to-regexp@6\.3\.0 compiles from published JS \(source overlay dropped: SC0001 at src\/index\.ts:\d+: Type 'string' is not assignable to type 'number'/, + ); + const published = await scriptc(["build", "routes.ts", "-o", "routes-pub"], broken, cacheDir); + expect(published.code).toBe(0); + expect(await run(join(broken, "routes-fb"))).toEqual(await run(join(broken, "routes-pub"))); + + // The published CommonJS build is what require() loads under Node, so + // the overlay steps aside; the build then fares exactly as without it. + const required = await scriptc( + ["build", "require.cts", "--sources", "-o", "require-src"], + project, + cacheDir, + ); + expect(required.stderr).toMatch( + /sources: path-to-regexp@6\.3\.0 compiles from published JS \(source overlay dropped: require\(\) of 'path-to-regexp'/, + ); + const requiredPublished = await scriptc( + ["build", "require.cts", "-o", "require-pub"], + project, + cacheDir, + ); + expect(required.code).toBe(requiredPublished.code); + }); +}); From bcf09c413d6efa9b2fce1de5d8a581fef7732dd8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 17 Oct 2026 19:46:29 -0700 Subject: [PATCH 3/6] spm: cache verification results and infer emit targets Warm syncs reuse the content check result, keyed by the integrity-pinned package and the commit-pinned tree. When a package's tsconfig cannot be read, candidate targets are bounded by the syntax and downlevel helpers in the published JS of the matching module format, and the target search stops early when the published entry has no counterpart. Adds the demo's measure.sh, which compiles every probe with and without extra scriptc flags and records coverage, build result and Node parity. Co-Authored-By: Claude Opus 5.5 --- examples/spm-demo/.gitignore | 1 + examples/spm-demo/measure.sh | 32 ++++++ examples/spm-demo/src/ts-api-utils.ts | 5 +- packages/pm/src/sync.ts | 49 ++++++-- packages/pm/src/verify.ts | 157 ++++++++++++++++++++++++-- 5 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 examples/spm-demo/measure.sh diff --git a/examples/spm-demo/.gitignore b/examples/spm-demo/.gitignore index 359c537c5d..972972eafd 100644 --- a/examples/spm-demo/.gitignore +++ b/examples/spm-demo/.gitignore @@ -2,3 +2,4 @@ node_modules/ .scriptc-pm/ .scriptc/ bin/ +results/ diff --git a/examples/spm-demo/measure.sh b/examples/spm-demo/measure.sh new file mode 100644 index 0000000000..721027d637 --- /dev/null +++ b/examples/spm-demo/measure.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Compile every probe in src/ with scriptc, with and without extra flags +# (e.g. --sources), and record coverage, build result and Node parity. +# sh measure.sh