refactor: code quality improvements across the board

This commit is contained in:
Aric Camarata 2026-03-08 11:33:21 -04:00
parent 1db4c24537
commit f21be803fe
11 changed files with 1221 additions and 344 deletions

View file

@ -8,6 +8,7 @@ on:
jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
@ -30,12 +31,32 @@ jobs:
run: pnpm run build:ts
- name: Run tests (ESM)
run: node test.mjs
run: node --test test.mjs
- name: Run tests (CJS)
run: node test-cjs.cjs
run: node --test test-cjs.cjs
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run format:check
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -53,6 +74,7 @@ jobs:
- run: pnpm run typecheck
pack-check:
name: Pack Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

6
.gitignore vendored
View file

@ -61,3 +61,9 @@ coverage/
# C compilation artifacts (WASM binary is pre-compiled and tracked)
*.o
*.a
.vscode/*
.codex/
.aider/
.aider.chat.history.md
.continue/
.gemini/

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

12
eslint.config.mjs Normal file
View file

@ -0,0 +1,12 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs', 'wasm/', 'src/spa.c', 'src/spa.h', 'validate.mjs'],
},
);

View file

@ -21,7 +21,10 @@
},
"sideEffects": false,
"files": [
"dist/",
"dist/index.cjs",
"dist/index.mjs",
"dist/index.d.ts",
"dist/index.d.mts",
"wasm/",
"README.md",
"CHANGELOG.md",
@ -33,7 +36,10 @@
"build": "pnpm run build:wasm && pnpm run build:ts",
"typecheck": "tsc --noEmit",
"pretest": "pnpm run build:ts",
"test": "node test.mjs && node test-cjs.cjs",
"test": "node --test test.mjs && node --test test-cjs.cjs",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"validate": "node validate.mjs",
"prepublishOnly": "pnpm run build:ts"
},
@ -67,8 +73,13 @@
"registry": "https://registry.npmjs.org/"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
}
}

View file

@ -8,15 +8,30 @@ importers:
.:
devDependencies:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.0.3)
'@types/node':
specifier: ^25.3.0
version: 25.3.0
eslint:
specifier: ^10.0.3
version: 10.0.3
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.0.3)
prettier:
specifier: ^3.8.1
version: 3.8.1
tsup:
specifier: ^8.5.1
version: 8.5.1(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
typescript-eslint:
specifier: ^8.56.1
version: 8.56.1(eslint@10.0.3)(typescript@5.9.3)
packages:
@ -176,6 +191,61 @@ packages:
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.23.3':
resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.5.3':
resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@1.1.1':
resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/js@10.0.1':
resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
peerDependencies:
eslint: ^10.0.0
peerDependenciesMeta:
eslint:
optional: true
'@eslint/object-schema@3.0.3':
resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/plugin-kit@0.6.1':
resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
'@humanwhocodes/retry@0.4.3':
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -327,20 +397,101 @@ packages:
cpu: [x64]
os: [win32]
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.56.1
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.56.1':
resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.56.1':
resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.56.1':
resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.56.1':
resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.56.1':
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.56.1':
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.56.1':
resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.56.1':
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.56.1':
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
engines: {node: 18 || 20 || >=22}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -366,6 +517,10 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -375,11 +530,75 @@ packages:
supports-color:
optional: true
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.0.3:
resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
jiti: '*'
peerDependenciesMeta:
jiti:
optional: true
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esquery@1.7.0:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -389,18 +608,76 @@ packages:
picomatch:
optional: true
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatted@3.3.4:
resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
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'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -412,9 +689,17 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@ -424,10 +709,33 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -463,6 +771,19 @@ packages:
yaml:
optional: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@ -476,6 +797,19 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
@ -503,6 +837,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@ -525,6 +865,17 @@ packages:
typescript:
optional: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
typescript-eslint@8.56.1:
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -536,6 +887,22 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
snapshots:
'@esbuild/aix-ppc64@0.27.3':
@ -616,6 +983,51 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.3)':
dependencies:
eslint: 10.0.3
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.3':
dependencies:
'@eslint/object-schema': 3.0.3
debug: 4.4.3
minimatch: 10.2.4
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.5.3':
dependencies:
'@eslint/core': 1.1.1
'@eslint/core@1.1.1':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/js@10.0.1(eslint@10.0.3)':
optionalDependencies:
eslint: 10.0.3
'@eslint/object-schema@3.0.3': {}
'@eslint/plugin-kit@0.6.1':
dependencies:
'@eslint/core': 1.1.1
levn: 0.4.1
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
dependencies:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.4.3
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -705,16 +1117,128 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
eslint: 10.0.3
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
'@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
debug: 4.4.3
eslint: 10.0.3
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.56.1': {}
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
eslint-visitor-keys: 5.0.1
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
any-promise@1.3.0: {}
balanced-match@4.0.4: {}
brace-expansion@5.0.4:
dependencies:
balanced-match: 4.0.4
bundle-require@5.1.0(esbuild@0.27.3):
dependencies:
esbuild: 0.27.3
@ -732,10 +1256,18 @@ snapshots:
consola@3.4.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-is@0.1.4: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@ -765,31 +1297,164 @@ snapshots:
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.3):
dependencies:
eslint: 10.0.3
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@5.0.1: {}
eslint@10.0.3:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.3
'@eslint/config-helpers': 0.5.3
'@eslint/core': 1.1.1
'@eslint/plugin-kit': 0.6.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
espree: 11.2.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.4
natural-compare: 1.4.0
optionator: 0.9.4
transitivePeerDependencies:
- supports-color
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 5.0.1
esquery@1.7.0:
dependencies:
estraverse: 5.3.0
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
esutils@2.0.3: {}
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
path-exists: 4.0.0
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.0
rollup: 4.59.0
flat-cache@4.0.1:
dependencies:
flatted: 3.3.4
keyv: 4.5.4
flatted@3.3.4: {}
fsevents@2.3.3:
optional: true
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
ignore@5.3.2: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
isexe@2.0.0: {}
joycon@3.1.1: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
type-check: 0.4.0
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
mlly@1.8.0:
dependencies:
acorn: 8.16.0
@ -805,8 +1470,31 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
natural-compare@1.4.0: {}
object-assign@4.1.1: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
type-check: 0.4.0
word-wrap: 1.2.5
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
path-exists@4.0.0: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
@ -825,6 +1513,12 @@ snapshots:
dependencies:
lilconfig: 3.1.3
prelude-ls@1.2.1: {}
prettier@3.8.1: {}
punycode@2.3.1: {}
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
@ -860,6 +1554,14 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
semver@7.7.4: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
source-map@0.7.6: {}
sucrase@3.35.1:
@ -889,6 +1591,10 @@ snapshots:
tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-interface-checker@0.1.13: {}
tsup@8.5.1(typescript@5.9.3):
@ -918,8 +1624,35 @@ snapshots:
- tsx
- yaml
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
typescript-eslint@8.56.1(eslint@10.0.3)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3)
eslint: 10.0.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@7.18.2: {}
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
which@2.0.2:
dependencies:
isexe: 2.0.0
word-wrap@1.2.5: {}
yocto-queue@0.1.0: {}

View file

@ -52,19 +52,36 @@ export function init(): Promise<void> {
if (_module) return Promise.resolve();
if (_pending) return _pending;
_pending = createSpaModule().then((mod: SpaWasmModule) => {
_module = mod;
_calculate = mod.cwrap('spa_calculate_wrapper', 'number', [
'number', 'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number', 'number',
'number', 'number', 'number', 'number', 'number', 'number',
]) as (...args: number[]) => number;
_free = mod.cwrap('spa_free_result', null, ['number']) as (ptr: number) => void;
_pending = null;
}).catch((err: unknown) => {
_pending = null;
throw err;
});
_pending = createSpaModule()
.then((mod: SpaWasmModule) => {
_module = mod;
_calculate = mod.cwrap('spa_calculate_wrapper', 'number', [
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
'number',
]) as (...args: number[]) => number;
_free = mod.cwrap('spa_free_result', null, ['number']) as (ptr: number) => void;
_pending = null;
})
.catch((err: unknown) => {
_pending = null;
throw err;
});
return _pending;
}
@ -72,21 +89,20 @@ export function init(): Promise<void> {
/**
* Format fractional hours to HH:MM:SS string.
* Returns "N/A" for non-finite or negative values (polar night/day scenarios).
*
* @param hours - Fractional hours (e.g. 6.5 for 06:30:00). Values >= 24 wrap.
* @returns Formatted time string in HH:MM:SS format, or "N/A" if input is invalid.
*/
export function formatTime(hours: number): string {
if (!isFinite(hours) || hours < 0) return 'N/A';
const totalSec = Math.round(hours * 3600);
// Wrap at 24h: values near midnight can round to 24:00:00
const h = Math.floor(totalSec / 3600) % 24;
const rem = totalSec - Math.floor(totalSec / 3600) * 3600;
const m = Math.floor(rem / 60);
const s = rem - m * 60;
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return (
String(h).padStart(2, '0') + ':' +
String(m).padStart(2, '0') + ':' +
String(s).padStart(2, '0')
String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
);
}
@ -94,16 +110,16 @@ export function formatTime(hours: number): string {
function readResult(ptr: number): SpaResult {
const m = _module!;
const result: SpaResult = {
zenith: m.getValue(ptr + OFFSET.zenith, 'double'),
azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, 'double'),
azimuth: m.getValue(ptr + OFFSET.azimuth, 'double'),
incidence: m.getValue(ptr + OFFSET.incidence, 'double'),
sunrise: m.getValue(ptr + OFFSET.sunrise, 'double'),
sunset: m.getValue(ptr + OFFSET.sunset, 'double'),
suntransit: m.getValue(ptr + OFFSET.suntransit, 'double'),
zenith: m.getValue(ptr + OFFSET.zenith, 'double'),
azimuth_astro: m.getValue(ptr + OFFSET.azimuth_astro, 'double'),
azimuth: m.getValue(ptr + OFFSET.azimuth, 'double'),
incidence: m.getValue(ptr + OFFSET.incidence, 'double'),
sunrise: m.getValue(ptr + OFFSET.sunrise, 'double'),
sunset: m.getValue(ptr + OFFSET.sunset, 'double'),
suntransit: m.getValue(ptr + OFFSET.suntransit, 'double'),
sun_transit_alt: m.getValue(ptr + OFFSET.sun_transit_alt, 'double'),
eot: m.getValue(ptr + OFFSET.eot, 'double'),
error_code: m.getValue(ptr + OFFSET.error_code, 'i32'),
eot: m.getValue(ptr + OFFSET.eot, 'double'),
error_code: m.getValue(ptr + OFFSET.error_code, 'i32'),
};
_free!(ptr);
return result;
@ -114,8 +130,34 @@ function readResult(ptr: number): SpaResult {
* @internal
*/
function assertFiniteNumber(value: unknown, name: string): asserts value is number {
if (typeof value !== 'number' || !isFinite(value)) {
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value === 'number' ? value : typeof value}`);
if (typeof value !== 'number') {
throw new TypeError(`SPA: ${name} must be a finite number, got ${typeof value}`);
}
if (!isFinite(value)) {
throw new RangeError(`SPA: ${name} must be a finite number, got ${value}`);
}
}
/** Field names in SpaOptions that must be finite numbers when provided. */
const NUMERIC_OPTION_FIELDS = [
'elevation',
'pressure',
'temperature',
'delta_t',
'slope',
'azm_rotation',
'atmos_refract',
] as const;
/**
* Validate numeric option fields. Each, if provided, must be a finite number.
* @internal
*/
function validateOptions(opts: SpaOptions): void {
for (const field of NUMERIC_OPTION_FIELDS) {
if (opts[field] !== undefined) {
assertFiniteNumber(opts[field], `options.${field}`);
}
}
}
@ -127,6 +169,8 @@ function assertFiniteNumber(value: unknown, name: string): asserts value is numb
* @param longitude - Observer longitude in degrees (-180 to 180)
* @param options - Optional parameters
* @returns Solar position result with all computed values
* @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers
* @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
*/
export async function spa(
date: Date,
@ -148,6 +192,10 @@ export async function spa(
throw new RangeError(`SPA: longitude must be between -180 and 180, got ${longitude}`);
}
if (options) {
validateOptions(options);
}
await init();
const opts = options ?? {};
@ -192,6 +240,9 @@ export async function spa(
*
* Same parameters as spa(). Returns sunrise, sunset, and suntransit
* as HH:MM:SS strings instead of fractional hours.
*
* @throws {TypeError} If date is not a valid Date, or if latitude/longitude/option fields are not numbers
* @throws {RangeError} If latitude/longitude are out of bounds, or if option fields are Infinity/NaN
*/
export async function spaFormatted(
date: Date,
@ -201,16 +252,16 @@ export async function spaFormatted(
): Promise<SpaFormattedResult> {
const result = await spa(date, latitude, longitude, options);
return {
zenith: result.zenith,
azimuth_astro: result.azimuth_astro,
azimuth: result.azimuth,
incidence: result.incidence,
sunrise: formatTime(result.sunrise),
sunset: formatTime(result.sunset),
suntransit: formatTime(result.suntransit),
zenith: result.zenith,
azimuth_astro: result.azimuth_astro,
azimuth: result.azimuth,
incidence: result.incidence,
sunrise: formatTime(result.sunrise),
sunset: formatTime(result.sunset),
suntransit: formatTime(result.suntransit),
sun_transit_alt: result.sun_transit_alt,
eot: result.eot,
error_code: result.error_code,
eot: result.eot,
error_code: result.error_code,
};
}

View file

@ -1,10 +1,17 @@
/** SPA function codes. Control which outputs are computed. */
/** Compute zenith and azimuth only. */
export const SPA_ZA = 0 as const;
/** Compute zenith, azimuth, and incidence angle. */
export const SPA_ZA_INC = 1 as const;
/** Compute zenith, azimuth, and rise/transit/set times. */
export const SPA_ZA_RTS = 2 as const;
/** Compute all outputs: zenith, azimuth, incidence, and rise/transit/set. */
export const SPA_ALL = 3 as const;
export type SpaFunctionCode = typeof SPA_ZA | typeof SPA_ZA_INC | typeof SPA_ZA_RTS | typeof SPA_ALL;
export type SpaFunctionCode =
| typeof SPA_ZA
| typeof SPA_ZA_INC
| typeof SPA_ZA_RTS
| typeof SPA_ALL;
export interface SpaOptions {
/**
@ -69,6 +76,6 @@ export interface SpaFormattedResult extends Omit<SpaResult, 'sunrise' | 'sunset'
* @internal
*/
export interface SpaWasmModule {
cwrap(name: string, returnType: string | null, argTypes: string[]): Function;
cwrap(name: string, returnType: string | null, argTypes: string[]): (...args: never[]) => unknown;
getValue(ptr: number, type: string): number;
}

View file

@ -1,58 +1,72 @@
'use strict';
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ALL } = require('./dist/index.cjs');
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(' FAIL: ' + message);
}
}
async function run() {
console.log('CJS smoke test\n');
// Verify all exports are available
assert(typeof spa === 'function', 'spa is a function');
assert(typeof spaFormatted === 'function', 'spaFormatted is a function');
assert(typeof formatTime === 'function', 'formatTime is a function');
assert(typeof init === 'function', 'init is a function');
assert(SPA_ZA === 0, 'SPA_ZA constant is 0');
assert(SPA_ALL === 3, 'SPA_ALL constant is 3');
// Core calculation
const result = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10 },
);
assert(result.error_code === 0, 'calculation succeeds');
assert(result.zenith > 0, 'zenith is positive');
assert(result.azimuth > 0, 'azimuth is positive');
assert(result.sunrise > 0, 'sunrise is positive');
// Formatted output
const fmt = await spaFormatted(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4 },
);
assert(typeof fmt.sunrise === 'string', 'formatted sunrise is a string');
assert(/^\d{2}:\d{2}:\d{2}$/.test(fmt.sunrise), 'sunrise matches HH:MM:SS');
// formatTime
assert(formatTime(6.5) === '06:30:00', 'formatTime works');
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch((err) => {
console.error(err);
process.exit(1);
describe('CJS exports', () => {
it('all exports are available', () => {
assert.equal(typeof spa, 'function');
assert.equal(typeof spaFormatted, 'function');
assert.equal(typeof formatTime, 'function');
assert.equal(typeof init, 'function');
assert.equal(SPA_ZA, 0);
assert.equal(SPA_ALL, 3);
});
});
describe('CJS spa()', () => {
it('core calculation succeeds', async () => {
const result = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10 },
);
assert.equal(result.error_code, 0);
assert.ok(result.zenith > 0);
assert.ok(result.azimuth > 0);
assert.ok(result.sunrise > 0);
});
});
describe('CJS spaFormatted()', () => {
it('returns formatted time strings', async () => {
const fmt = await spaFormatted(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4 },
);
assert.equal(typeof fmt.sunrise, 'string');
assert.match(fmt.sunrise, /^\d{2}:\d{2}:\d{2}$/);
});
});
describe('CJS formatTime()', () => {
it('formats correctly', () => {
assert.equal(formatTime(6.5), '06:30:00');
});
});
describe('CJS option validation', () => {
it('rejects non-number elevation', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { elevation: 'high' }), TypeError);
});
it('rejects Infinity pressure', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { pressure: Infinity }), RangeError);
});
it('rejects NaN temperature', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { temperature: NaN }), RangeError);
});
it('accepts valid numeric options', async () => {
const result = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, {
timezone: -4,
elevation: 100,
pressure: 1000,
temperature: 25,
});
assert.equal(result.error_code, 0);
});
});

499
test.mjs
View file

@ -1,258 +1,271 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spa, spaFormatted, formatTime, init, SPA_ZA, SPA_ZA_INC, SPA_ZA_RTS, SPA_ALL } from './dist/index.mjs';
let passed = 0;
let failed = 0;
function assert(condition, message) {
if (condition) {
passed++;
} else {
failed++;
console.error(' FAIL: ' + message);
}
}
function approx(actual, expected, tolerance, label) {
const diff = Math.abs(actual - expected);
assert(diff <= tolerance, label + ': expected ' + expected + ', got ' + actual + ' (diff: ' + diff.toFixed(6) + ')');
assert.ok(diff <= tolerance, `${label}: expected ${expected}, got ${actual} (diff: ${diff.toFixed(6)})`);
}
async function assertThrows(fn, check, label) {
try {
await fn();
assert(false, label + ': should have thrown');
} catch (e) {
if (check) {
assert(check(e), label + ': ' + e.message);
} else {
passed++;
}
}
}
describe('spa()', () => {
it('NYC, April 1 2023, midnight local (UTC-4)', async () => {
const nyc = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
approx(nyc.zenith, 132.82, 0.1, 'zenith');
approx(nyc.azimuth, 339.38, 0.1, 'azimuth');
approx(nyc.sunrise, 6.665, 0.01, 'sunrise');
approx(nyc.sunset, 19.343, 0.01, 'sunset');
approx(nyc.suntransit, 12.998, 0.01, 'solar noon');
approx(nyc.sun_transit_alt, 53.916, 0.1, 'transit altitude');
assert.equal(nyc.error_code, 0);
});
async function run() {
console.log('solar-spa test suite\n');
it('London, June 21 2025, noon UTC', async () => {
const london = await spa(
new Date(2025, 5, 21, 12, 0, 0),
51.5074, -0.1278,
{ timezone: 0, elevation: 11, temperature: 18 },
);
assert.ok(london.zenith < 30, 'zenith near noon is below 30 degrees');
assert.ok(london.azimuth > 170 && london.azimuth < 200, 'azimuth roughly south at noon');
assert.ok(london.sunrise > 3 && london.sunrise < 6, 'sunrise between 3 and 6');
assert.ok(london.sunset > 19 && london.sunset < 23, 'sunset between 19 and 23');
assert.equal(london.error_code, 0);
});
// ── Test 1: New York City, April 1 2023 ──
console.log('1. NYC, April 1 2023, midnight local (UTC-4)');
const nyc = await spa(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
approx(nyc.zenith, 132.82, 0.1, 'zenith');
approx(nyc.azimuth, 339.38, 0.1, 'azimuth');
approx(nyc.sunrise, 6.665, 0.01, 'sunrise');
approx(nyc.sunset, 19.343, 0.01, 'sunset');
approx(nyc.suntransit, 12.998, 0.01, 'solar noon');
approx(nyc.sun_transit_alt, 53.916, 0.1, 'transit altitude');
assert(nyc.error_code === 0, 'error_code is 0');
it('Quito (equator), March 20 2025, noon UTC-5', async () => {
const quito = await spa(
new Date(2025, 2, 20, 12, 0, 0),
-0.1807, -78.4678,
{ timezone: -5, elevation: 2850 },
);
assert.ok(quito.zenith < 20, 'near-overhead sun at equinox on equator');
assert.equal(quito.error_code, 0);
});
// ── Test 2: London, Summer Solstice ──
console.log('2. London, June 21 2025, noon UTC');
const london = await spa(
new Date(2025, 5, 21, 12, 0, 0),
51.5074, -0.1278,
{ timezone: 0, elevation: 11, temperature: 18 },
);
assert(london.zenith < 30, 'zenith near noon is below 30 degrees');
assert(london.azimuth > 170 && london.azimuth < 200, 'azimuth roughly south at noon');
assert(london.sunrise > 3 && london.sunrise < 6, 'sunrise between 3 and 6');
assert(london.sunset > 19 && london.sunset < 23, 'sunset between 19 and 23');
assert(london.error_code === 0, 'error_code is 0');
it('Sydney, June 21 2025 (winter), noon AEST', async () => {
const sydney = await spa(
new Date(2025, 5, 21, 12, 0, 0),
-33.8688, 151.2093,
{ timezone: 10 },
);
assert.ok(sydney.zenith > 50, 'low sun in southern winter');
assert.ok(sydney.sunrise > 6 && sydney.sunrise < 8, 'winter sunrise after 6');
assert.equal(sydney.error_code, 0);
});
// ── Test 3: Equator, Equinox ──
console.log('3. Quito (equator), March 20 2025, noon UTC-5');
const quito = await spa(
new Date(2025, 2, 20, 12, 0, 0),
-0.1807, -78.4678,
{ timezone: -5, elevation: 2850 },
);
assert(quito.zenith < 20, 'near-overhead sun at equinox on equator');
assert(quito.error_code === 0, 'error_code is 0');
it('repeated calls produce different results', async () => {
const a = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
const b = await spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 });
assert.notEqual(a.zenith, b.zenith);
assert.equal(a.error_code, 0);
assert.equal(b.error_code, 0);
});
// ── Test 4: Sydney, Winter ──
console.log('4. Sydney, June 21 2025 (winter), noon AEST');
const sydney = await spa(
new Date(2025, 5, 21, 12, 0, 0),
-33.8688, 151.2093,
{ timezone: 10 },
);
assert(sydney.zenith > 50, 'low sun in southern winter');
assert(sydney.sunrise > 6 && sydney.sunrise < 8, 'winter sunrise after 6');
assert(sydney.error_code === 0, 'error_code is 0');
it('concurrent calls all succeed', async () => {
const [c1, c2, c3] = await Promise.all([
spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 }),
spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
]);
assert.equal(c1.error_code, 0);
assert.equal(c2.error_code, 0);
assert.equal(c3.error_code, 0);
assert.notEqual(c1.zenith, c2.zenith);
});
// ── Test 5: Formatted output ──
console.log('5. Formatted output (NYC)');
const fmt = await spaFormatted(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
assert(typeof fmt.sunrise === 'string', 'sunrise is a string');
assert(typeof fmt.sunset === 'string', 'sunset is a string');
assert(typeof fmt.suntransit === 'string', 'suntransit is a string');
assert(/^\d{2}:\d{2}:\d{2}$/.test(fmt.sunrise), 'sunrise matches HH:MM:SS');
assert(typeof fmt.zenith === 'number', 'zenith remains numeric');
assert(typeof fmt.error_code === 'number', 'error_code is present in formatted result');
it('boundary coordinates (poles and date line)', async () => {
const northPole = await spa(new Date(2025, 5, 21, 12, 0, 0), 90, 0, { timezone: 0 });
assert.equal(northPole.error_code, 0);
const southPole = await spa(new Date(2025, 5, 21, 12, 0, 0), -90, 0, { timezone: 0 });
assert.equal(southPole.error_code, 0);
const dateLine = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, 180, { timezone: 12 });
assert.equal(dateLine.error_code, 0);
const dateLineNeg = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, -180, { timezone: -12 });
assert.equal(dateLineNeg.error_code, 0);
});
// ── Test 6: formatTime utility ──
console.log('6. formatTime utility');
assert(formatTime(0) === '00:00:00', 'midnight');
assert(formatTime(12) === '12:00:00', 'noon');
assert(formatTime(6.5) === '06:30:00', '6.5 hours');
assert(formatTime(23.9997) === '23:59:59', 'end of day');
assert(formatTime(Infinity) === 'N/A', 'Infinity returns N/A');
assert(formatTime(-Infinity) === 'N/A', '-Infinity returns N/A');
assert(formatTime(NaN) === 'N/A', 'NaN returns N/A');
assert(formatTime(-1) === 'N/A', 'negative returns N/A');
assert(formatTime(-0.5) === 'N/A', 'negative fractional returns N/A');
assert(formatTime(24.0) === '00:00:00', '24h wraps to midnight');
assert(formatTime(24.5) === '00:30:00', '24.5h wraps to 00:30');
it('arctic polar day', async () => {
const tromso = await spa(
new Date(2025, 5, 21, 12, 0, 0),
69.6496, 18.9560,
{ timezone: 2 },
);
assert.equal(tromso.error_code, 0);
assert.ok(tromso.zenith < 50, 'sun is high at Tromso in summer');
});
// ── Test 7: SPA error handling ──
console.log('7. SPA error handling');
await assertThrows(
() => spa(new Date(2023, 0, 1), 40, -74, { timezone: 100 }),
(e) => e.message.includes('error code'),
'invalid timezone throws with error code',
);
// ── Test 8: Input validation ──
console.log('8. Input validation');
await assertThrows(
() => spa(null, 40, -74),
(e) => e instanceof TypeError,
'null date throws TypeError',
);
await assertThrows(
() => spa(new Date('invalid'), 40, -74),
(e) => e instanceof TypeError,
'invalid date throws TypeError',
);
await assertThrows(
() => spa(new Date(), 'forty', -74),
(e) => e instanceof TypeError,
'string latitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 40, undefined),
(e) => e instanceof TypeError,
'undefined longitude throws TypeError',
);
await assertThrows(
() => spa(new Date(), 91, -74),
(e) => e instanceof RangeError,
'latitude > 90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), -91, -74),
(e) => e instanceof RangeError,
'latitude < -90 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, 181),
(e) => e instanceof RangeError,
'longitude > 180 throws RangeError',
);
await assertThrows(
() => spa(new Date(), 40, -181),
(e) => e instanceof RangeError,
'longitude < -180 throws RangeError',
);
// ── Test 9: Function code selection ──
console.log('9. Function code SPA_ZA (zenith/azimuth only)');
const za = await spa(
new Date(2023, 3, 1, 12, 0, 0),
40.7128, -74.006,
{ timezone: -4, function: SPA_ZA },
);
assert(za.zenith > 0, 'zenith computed');
assert(za.azimuth > 0, 'azimuth computed');
assert(za.error_code === 0, 'error_code is 0');
// ── Test 10: Repeated calls (verify singleton init) ──
console.log('10. Repeated calls');
const a = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
const b = await spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 });
assert(a.zenith !== b.zenith, 'different dates produce different results');
assert(a.error_code === 0 && b.error_code === 0, 'both succeed');
// ── Test 11: Concurrent calls (verify init dedup) ──
console.log('11. Concurrent calls');
const [c1, c2, c3] = await Promise.all([
spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 }),
spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
spa(new Date(2023, 6, 1, 12, 0, 0), 40, -74, { timezone: -4 }),
]);
assert(c1.error_code === 0 && c2.error_code === 0 && c3.error_code === 0, 'all three concurrent calls succeed');
assert(c1.zenith !== c2.zenith, 'concurrent results differ by date');
// ── Test 12: Boundary coordinates ──
console.log('12. Boundary coordinates');
const northPole = await spa(new Date(2025, 5, 21, 12, 0, 0), 90, 0, { timezone: 0 });
assert(northPole.error_code === 0, 'north pole succeeds');
const southPole = await spa(new Date(2025, 5, 21, 12, 0, 0), -90, 0, { timezone: 0 });
assert(southPole.error_code === 0, 'south pole succeeds');
const dateLine = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, 180, { timezone: 12 });
assert(dateLine.error_code === 0, 'date line (180) succeeds');
const dateLineNeg = await spa(new Date(2025, 5, 21, 12, 0, 0), 0, -180, { timezone: -12 });
assert(dateLineNeg.error_code === 0, 'date line (-180) succeeds');
// ── Test 13: Arctic polar day (sun never sets) ──
console.log('13. Arctic polar day');
const tromso = await spa(
new Date(2025, 5, 21, 12, 0, 0),
69.6496, 18.9560,
{ timezone: 2 },
);
assert(tromso.error_code === 0, 'Tromso summer succeeds');
// During polar day, sunrise/sunset values from SPA may be non-standard
// The key is that the computation succeeds and zenith is low (sun is up)
assert(tromso.zenith < 50, 'sun is high at Tromso in summer');
// ── Test 14: Explicit init() call ──
console.log('14. Explicit init()');
await init(); // should be a no-op since module is already loaded
const afterInit = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
assert(afterInit.error_code === 0, 'spa works after explicit init');
// ── Test 15: Constants are correct ──
console.log('15. Constants');
assert(SPA_ZA === 0, 'SPA_ZA is 0');
assert(SPA_ZA_INC === 1, 'SPA_ZA_INC is 1');
assert(SPA_ZA_RTS === 2, 'SPA_ZA_RTS is 2');
assert(SPA_ALL === 3, 'SPA_ALL is 3');
// ── Test 16: Historical date ──
console.log('16. Historical date (year 1000)');
const historical = await spa(
new Date(1000, 5, 21, 12, 0, 0),
40.7128, -74.006,
{ timezone: -5, delta_t: 1574 },
);
assert(historical.error_code === 0, 'historical date succeeds');
assert(historical.zenith > 0 && historical.zenith < 90, 'historical zenith is reasonable');
// ── Test 17: All function codes ──
console.log('17. All function codes');
const zaRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA });
const zaIncRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_INC });
const zaRtsRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA_RTS });
const allRes = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ALL });
assert(zaRes.error_code === 0, 'SPA_ZA succeeds');
assert(zaIncRes.error_code === 0, 'SPA_ZA_INC succeeds');
assert(zaRtsRes.error_code === 0, 'SPA_ZA_RTS succeeds');
assert(allRes.error_code === 0, 'SPA_ALL succeeds');
approx(zaRes.zenith, allRes.zenith, 0.001, 'zenith consistent across function codes');
// ── Results ──
console.log('\n' + passed + ' passed, ' + failed + ' failed');
if (failed > 0) process.exit(1);
}
run().catch(function (err) {
console.error(err);
process.exit(1);
it('historical date (year 1000)', async () => {
const historical = await spa(
new Date(1000, 5, 21, 12, 0, 0),
40.7128, -74.006,
{ timezone: -5, delta_t: 1574 },
);
assert.equal(historical.error_code, 0);
assert.ok(historical.zenith > 0 && historical.zenith < 90, 'historical zenith is reasonable');
});
});
describe('function codes', () => {
it('SPA_ZA computes zenith and azimuth', async () => {
const res = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, { timezone: -4, function: SPA_ZA });
assert.ok(res.zenith > 0);
assert.ok(res.azimuth > 0);
assert.equal(res.error_code, 0);
});
it('all function codes succeed with consistent zenith', async () => {
const args = [new Date(2023, 3, 1, 12, 0, 0), 40, -74];
const opts = { timezone: -4 };
const zaRes = await spa(...args, { ...opts, function: SPA_ZA });
const zaIncRes = await spa(...args, { ...opts, function: SPA_ZA_INC });
const zaRtsRes = await spa(...args, { ...opts, function: SPA_ZA_RTS });
const allRes = await spa(...args, { ...opts, function: SPA_ALL });
assert.equal(zaRes.error_code, 0);
assert.equal(zaIncRes.error_code, 0);
assert.equal(zaRtsRes.error_code, 0);
assert.equal(allRes.error_code, 0);
approx(zaRes.zenith, allRes.zenith, 0.001, 'zenith consistent across function codes');
});
});
describe('spaFormatted()', () => {
it('returns formatted time strings', async () => {
const fmt = await spaFormatted(
new Date(2023, 3, 1, 0, 0, 0),
40.7128, -74.006,
{ timezone: -4, elevation: 10, temperature: 20, pressure: 1013.25 },
);
assert.equal(typeof fmt.sunrise, 'string');
assert.equal(typeof fmt.sunset, 'string');
assert.equal(typeof fmt.suntransit, 'string');
assert.match(fmt.sunrise, /^\d{2}:\d{2}:\d{2}$/);
assert.equal(typeof fmt.zenith, 'number');
assert.equal(typeof fmt.error_code, 'number');
});
});
describe('formatTime()', () => {
it('formats standard values', () => {
assert.equal(formatTime(0), '00:00:00');
assert.equal(formatTime(12), '12:00:00');
assert.equal(formatTime(6.5), '06:30:00');
assert.equal(formatTime(23.9997), '23:59:59');
});
it('wraps at 24h', () => {
assert.equal(formatTime(24.0), '00:00:00');
assert.equal(formatTime(24.5), '00:30:00');
});
it('returns N/A for invalid inputs', () => {
assert.equal(formatTime(Infinity), 'N/A');
assert.equal(formatTime(-Infinity), 'N/A');
assert.equal(formatTime(NaN), 'N/A');
assert.equal(formatTime(-1), 'N/A');
assert.equal(formatTime(-0.5), 'N/A');
});
});
describe('input validation', () => {
it('rejects null date', async () => {
await assert.rejects(() => spa(null, 40, -74), TypeError);
});
it('rejects invalid date', async () => {
await assert.rejects(() => spa(new Date('invalid'), 40, -74), TypeError);
});
it('rejects string latitude', async () => {
await assert.rejects(() => spa(new Date(), 'forty', -74), TypeError);
});
it('rejects undefined longitude', async () => {
await assert.rejects(() => spa(new Date(), 40, undefined), TypeError);
});
it('rejects latitude > 90', async () => {
await assert.rejects(() => spa(new Date(), 91, -74), RangeError);
});
it('rejects latitude < -90', async () => {
await assert.rejects(() => spa(new Date(), -91, -74), RangeError);
});
it('rejects longitude > 180', async () => {
await assert.rejects(() => spa(new Date(), 40, 181), RangeError);
});
it('rejects longitude < -180', async () => {
await assert.rejects(() => spa(new Date(), 40, -181), RangeError);
});
it('rejects invalid timezone in SPA engine', async () => {
await assert.rejects(
() => spa(new Date(2023, 0, 1), 40, -74, { timezone: 100 }),
(err) => err.message.includes('error code'),
);
});
});
describe('option validation', () => {
it('rejects non-number elevation', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { elevation: 'high' }), TypeError);
});
it('rejects Infinity pressure', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { pressure: Infinity }), RangeError);
});
it('rejects NaN temperature', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { temperature: NaN }), RangeError);
});
it('rejects non-number delta_t', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { delta_t: true }), TypeError);
});
it('rejects non-number slope', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { slope: null }), TypeError);
});
it('rejects Infinity azm_rotation', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { azm_rotation: -Infinity }), RangeError);
});
it('rejects non-number atmos_refract', async () => {
await assert.rejects(() => spa(new Date(), 40, -74, { atmos_refract: '0.5667' }), TypeError);
});
it('accepts valid numeric options', async () => {
const result = await spa(new Date(2023, 3, 1, 12, 0, 0), 40, -74, {
timezone: -4,
elevation: 100,
pressure: 1000,
temperature: 25,
delta_t: 69,
slope: 10,
azm_rotation: 180,
atmos_refract: 0.5,
});
assert.equal(result.error_code, 0);
});
});
describe('init()', () => {
it('explicit init is a no-op after module is loaded', async () => {
await init();
const result = await spa(new Date(2023, 0, 1, 12, 0, 0), 40, -74, { timezone: -5 });
assert.equal(result.error_code, 0);
});
});
describe('constants', () => {
it('SPA_ZA is 0', () => assert.equal(SPA_ZA, 0));
it('SPA_ZA_INC is 1', () => assert.equal(SPA_ZA_INC, 1));
it('SPA_ZA_RTS is 2', () => assert.equal(SPA_ZA_RTS, 2));
it('SPA_ALL is 3', () => assert.equal(SPA_ALL, 3));
});

View file

@ -10,6 +10,8 @@
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",