diff --git a/.gitignore b/.gitignore index 47c195e..101037f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ node_modules +.DS_Store +types/ test/fixtures/*.actual.css -src/parser.js yarn-error.log +reports/ +.stryker-tmp/ +test/corpus/github/files/ +test/corpus/github/.harvest-state.json diff --git a/README.md b/README.md index 2c20740..cd2635d 100755 --- a/README.md +++ b/README.md @@ -120,6 +120,61 @@ div[data-size="calc(3*3)"] { } ``` +#### `onParseError` + +Callback invoked when a `calc()` body fails to parse or simplify. Matches +[`@csstools/css-calc`][csstools-css-calc]'s shape: + +```js +calc({ + onParseError: (err, input) => { + throw err; // or log, route to a different channel, etc. + } +}) +``` + +When omitted, errors are reported via PostCSS `result.warn()` so the +plugin never throws at the postcss level. + +### Behavior differences from the legacy parser + +The legacy [jison][jison]-generated parser was replaced by a hand-written +Pratt parser whose simplifier follows [CSS Values 4][css-values-4]. Most +inputs reduce to identical output; the differences are spec-aligned or +canonical-form decisions: + +- **Strict whitespace (§10.1).** `calc(2px+3px)` is invalid CSS (binary + `+` / `-` require surrounding whitespace) and is preserved with a + warning instead of reduced. +- **Canonical operand order.** Commutative operands serialize + numeric-first, matching [`@csstools/css-calc`][csstools-css-calc]: + `calc(var(--foo) + 10px)` → `calc(10px + var(--foo))`. +- **Zero buckets are kept.** `calc(100px - (100px - 100%))` → + `calc(0px + 100%)`, not `100%` — [WPT calc-serialization-002][wpt-calc-serialization] + requires the zero term because it carries the length-percentage type. +- **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1). + Previously `pi` / `e` stayed symbolic. +- **Reciprocal conversion.** `calc(var(--x) / 2)` becomes + `calc(var(--x) * 0.5)`. The two are mathematically equivalent; + previously the division shape was kept. +- **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes + `calc(50vw - 5px)`. +- **Unit case normalization.** `2PX` becomes `2px` (CSS units are case- + insensitive; lowercase is conventional). +- **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a + `calc()` containing a single value is replaced by that value. +- **Spec-style spaced operators.** `2px*var(--x)` is serialized as + `2px * var(--x)`. The tokenizer is unaffected; only output spacing + differs. +- **Division by zero / by a unit.** `calc(500px/0)` reduces to + `calc(infinity * 1px)` (§10.13) instead of throwing. Use `onParseError` + if you want validation behavior. + +[css-values-4]: https://www.w3.org/TR/css-values-4/ +[csstools-css-calc]: https://www.npmjs.com/package/@csstools/css-calc +[wpt-calc-serialization]: https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization-002.html +[jison]: https://github.com/zaach/jison + --- ## Related PostCSS plugins @@ -149,5 +204,5 @@ npm test [PostCSS]: https://github.com/postcss [PostCSS Calc]: https://github.com/postcss/postcss-calc [PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties -[tests]: src/__tests__/index.js +[tests]: test/index.js [W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation diff --git a/eslint.config.js b/eslint.config.js index 5e4017b..e2ff9f5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,18 +1,63 @@ const js = require('@eslint/js'); const eslintConfigPrettier = require('eslint-config-prettier'); +const sonarjs = require('eslint-plugin-sonarjs'); module.exports = [ { - ignores: ['src/parser.js'], + ignores: ['node_modules/**', '.stryker-tmp/**', 'reports/**', 'types/**'], }, js.configs.recommended, + // SonarJS — code smells, cognitive complexity, dead stores, etc. + { + files: ['src/**/*.js', 'test/**/*.mjs', 'scripts/**/*.mjs'], + plugins: { sonarjs }, + rules: { + ...sonarjs.configs.recommended.rules, + // Math hot paths (simplify, tokenizer, foldConstArgs, naive oracles) + // have intrinsic complexity that's not extractable without diluting + // single-pass intent. Default 15 is too tight; 25 still flags real + // accidental complexity. + 'sonarjs/cognitive-complexity': ['error', 25], + }, + }, + { + rules: { + // Underscore-prefix is the convention for "intentionally unused". + 'no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, eslintConfigPrettier, { + files: ['src/**/*.js', 'test/**/*.js', 'eslint.config.js'], languageOptions: { sourceType: 'commonjs', + globals: { + process: 'readonly', + require: 'readonly', + module: 'readonly', + __dirname: 'readonly', + Buffer: 'readonly', + }, }, rules: { curly: 'error', }, }, + { + files: ['**/*.mjs'], + languageOptions: { + sourceType: 'module', + globals: { + console: 'readonly', + performance: 'readonly', + process: 'readonly', + fetch: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + }, + }, + }, ]; diff --git a/package.json b/package.json index 820b151..445e6cb 100644 --- a/package.json +++ b/package.json @@ -22,31 +22,38 @@ "LICENSE" ], "scripts": { - "prepare": "pnpm run build && tsc", - "build": "jison ./parser.jison -o src/parser.js", "lint": "eslint . && tsc", - "test": "node --test" + "test": "node --test", + "test:mutation": "stryker run", + "quality:type-coverage": "type-coverage --at-least 97.5", + "quality": "pnpm quality:type-coverage && pnpm lint" }, "author": "Andy Jansson", "license": "MIT", "engines": { - "node": "^18.12 || ^20.9 || >=22.0" + "node": ">=22.12" }, "devDependencies": { + "@csstools/css-calc": "^3.2.0", "@eslint/js": "^10.0.1", + "@rmenke/css-tokenizer-tests": "^1.2.0", + "@stryker-mutator/core": "^9.6.1", + "@stryker-mutator/typescript-checker": "^9.6.1", "@types/node": "^25.9.3", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", - "jison-gho": "0.6.1-216", + "eslint-plugin-sonarjs": "^4.0.3", + "fast-check": "^4.7.0", "postcss": "^8.5.15", "prettier": "^3.8.4", + "type-coverage": "^2.29.7", "typescript": "~6.0.3" }, "dependencies": { - "postcss-selector-parser": "^7.1.4", + "@csstools/css-tokenizer": "^4.0.0", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.4.38" } -} +} \ No newline at end of file diff --git a/parser.jison b/parser.jison deleted file mode 100644 index 575aabd..0000000 --- a/parser.jison +++ /dev/null @@ -1,174 +0,0 @@ -/* description: Parses expressions. */ - -/* lexical grammar */ -%lex - -%options case-insensitive - -%% -\s+ /* skip whitespace */ - -(\-(webkit|moz)\-)?calc\b return 'CALC'; - -[a-z][a-z0-9-]*\s*\((?:(?:\"(?:\\.|[^\"\\])*\"|\'(?:\\.|[^\'\\])*\')|\([^)]*\)|[^\(\)]*)*\) return 'FUNCTION'; - -"*" return 'MUL'; -"/" return 'DIV'; -"+" return 'ADD'; -"-" return 'SUB'; - -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)em\b return 'EMS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)ex\b return 'EXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)ch\b return 'CHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)rem\b return 'REMS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vw\b return 'VWS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svw\b return 'SVWS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvw\b return 'LVWS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvw\b return 'DVWS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vh\b return 'VHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svh\b return 'SVHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvh\b return 'LVHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvh\b return 'DVHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vmin\b return 'VMINS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svmin\b return 'SVMINS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvmin\b return 'LVMINS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvmin\b return 'DVMINS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vmax\b return 'VMAXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svmax\b return 'SVMAXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvmax\b return 'LVMAXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvmax\b return 'DVMAXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vb\b return 'VBS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svb\b return 'SVBS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvb\b return 'LVBS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvb\b return 'DVBS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)vi\b return 'VIS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)svi\b return 'SVIS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lvi\b return 'LVIS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dvi\b return 'DVIS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqw\b return 'CQWS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqh\b return 'CQHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqi\b return 'CQIS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqb\b return 'CQBS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqmin\b return 'CQMINS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cqmax\b return 'CQMAXS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)lh\b return 'LHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)rlh\b return 'RLHS'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)cm\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)mm\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)Q\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)in\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)pt\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)pc\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)px\b return 'LENGTH'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)deg\b return 'ANGLE'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)grad\b return 'ANGLE'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)rad\b return 'ANGLE'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)turn\b return 'ANGLE'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)s\b return 'TIME'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)ms\b return 'TIME'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)Hz\b return 'FREQ'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)kHz\b return 'FREQ'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dpi\b return 'RES'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dpcm\b return 'RES'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)dppx\b return 'RES'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)\% return 'PERCENTAGE'; -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)\b return 'NUMBER'; -("infinity"|"pi"|"e")\b return 'CALC_KEYWORD'; - - -(([0-9]+("."[0-9]+)?|"."[0-9]+)(e(\+|-)[0-9]+)?)-?([a-zA-Z_]|[\240-\377]|(\\[0-9a-fA-F]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-fA-F]))([a-zA-Z0-9_-]|[\240-\377]|(\\[0-9a-fA-F]{1,6}(\r\n|[ \t\r\n\f])?|\\[^\r\n\f0-9a-fA-F]))*\b return 'UNKNOWN_DIMENSION'; - -"(" return 'LPAREN'; -")" return 'RPAREN'; - -<> return 'EOF'; - -/lex - -%left ADD SUB -%left MUL DIV -%left UPREC - - -%start expression - -%% - -expression - : math_expression EOF { return $1; } - ; - - math_expression - : CALC LPAREN math_expression RPAREN { $$ = $3; } - | math_expression ADD math_expression { $$ = { type: 'MathExpression', operator: $2, left: $1, right: $3 }; } - | math_expression SUB math_expression { $$ = { type: 'MathExpression', operator: $2, left: $1, right: $3 }; } - | math_expression MUL math_expression { $$ = { type: 'MathExpression', operator: $2, left: $1, right: $3 }; } - | math_expression DIV math_expression { $$ = { type: 'MathExpression', operator: $2, left: $1, right: $3 }; } - | LPAREN math_expression RPAREN { $$ = { type: 'ParenthesizedExpression', content: $2 }; } - | function { $$ = $1; } - | dimension { $$ = $1; } - | number { $$ = $1; } - | calc_keyword { $$ = $1; } - ; - - function - : FUNCTION { $$ = { type: 'Function', value: $1 }; } - ; - - dimension - : LENGTH { $$ = { type: 'LengthValue', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | ANGLE { $$ = { type: 'AngleValue', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | TIME { $$ = { type: 'TimeValue', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | FREQ { $$ = { type: 'FrequencyValue', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | RES { $$ = { type: 'ResolutionValue', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | UNKNOWN_DIMENSION { $$ = { type: 'UnknownDimension', value: parseFloat($1), unit: /[a-z]+$/i.exec($1)[0] }; } - | EMS { $$ = { type: 'EmValue', value: parseFloat($1), unit: 'em' }; } - | EXS { $$ = { type: 'ExValue', value: parseFloat($1), unit: 'ex' }; } - | CHS { $$ = { type: 'ChValue', value: parseFloat($1), unit: 'ch' }; } - | REMS { $$ = { type: 'RemValue', value: parseFloat($1), unit: 'rem' }; } - | VHS { $$ = { type: 'VhValue', value: parseFloat($1), unit: 'vh' }; } - | SVHS { $$ = { type: 'SvhValue', value: parseFloat($1), unit: 'svh' }; } - | LVHS { $$ = { type: 'LvhValue', value: parseFloat($1), unit: 'lvh' }; } - | DVHS { $$ = { type: 'DvhValue', value: parseFloat($1), unit: 'dvh' }; } - | VWS { $$ = { type: 'VwValue', value: parseFloat($1), unit: 'vw' }; } - | SVWS { $$ = { type: 'SvwValue', value: parseFloat($1), unit: 'svw' }; } - | LVWS { $$ = { type: 'LvwValue', value: parseFloat($1), unit: 'lvw' }; } - | DVWS { $$ = { type: 'DvwValue', value: parseFloat($1), unit: 'dvw' }; } - | VMINS { $$ = { type: 'VminValue', value: parseFloat($1), unit: 'vmin' }; } - | SVMINS { $$ = { type: 'SvminValue', value: parseFloat($1), unit: 'svmin' }; } - | LVMINS { $$ = { type: 'LvminValue', value: parseFloat($1), unit: 'lvmin' }; } - | DVMINS { $$ = { type: 'DvminValue', value: parseFloat($1), unit: 'dvmin' }; } - | VMAXS { $$ = { type: 'VmaxValue', value: parseFloat($1), unit: 'vmax' }; } - | SVMAXS { $$ = { type: 'SvmaxValue', value: parseFloat($1), unit: 'svmax' }; } - | LVMAXS { $$ = { type: 'LvmaxValue', value: parseFloat($1), unit: 'lvmax' }; } - | DVMAXS { $$ = { type: 'DvmaxValue', value: parseFloat($1), unit: 'dvmax' }; } - | VBS { $$ = { type: 'VbValue', value: parseFloat($1), unit: 'vb' }; } - | SVBS { $$ = { type: 'SvbValue', value: parseFloat($1), unit: 'svb' }; } - | LVBS { $$ = { type: 'LvbValue', value: parseFloat($1), unit: 'lvb' }; } - | DVBS { $$ = { type: 'DvbValue', value: parseFloat($1), unit: 'dvb' }; } - | VIS { $$ = { type: 'VhValue', value: parseFloat($1), unit: 'vi' }; } - | SVIS { $$ = { type: 'SvhValue', value: parseFloat($1), unit: 'svi' }; } - | LVIS { $$ = { type: 'LvhValue', value: parseFloat($1), unit: 'lvi' }; } - | DVIS { $$ = { type: 'DvhValue', value: parseFloat($1), unit: 'dvi' }; } - | CQWS { $$ = { type: 'CqwValue', value: parseFloat($1), unit: 'cqw' }; } - | CQHS { $$ = { type: 'CqhValue', value: parseFloat($1), unit: 'cqh' }; } - | CQIS { $$ = { type: 'CqiValue', value: parseFloat($1), unit: 'cqi' }; } - | CQBS { $$ = { type: 'CqbValue', value: parseFloat($1), unit: 'cqb' }; } - | CQMINS { $$ = { type: 'CqminValue', value: parseFloat($1), unit: 'cqmin' }; } - | CQMAXS { $$ = { type: 'CqmaxValue', value: parseFloat($1), unit: 'cqmax' }; } - | LHS { $$ = { type: 'LhValue', value: parseFloat($1), unit: 'lh' }; } - | RLHS { $$ = { type: 'RlhValue', value: parseFloat($1), unit: 'rlh' }; } - | PERCENTAGE { $$ = { type: 'PercentageValue', value: parseFloat($1), unit: '%' }; } - | ADD dimension { var prev = $2; $$ = prev; } - | SUB dimension { var prev = $2; prev.value *= -1; $$ = prev; } - ; - - calc_keyword - : CALC_KEYWORD { $$ = { type: 'CalcKeyword', value: $1 }; } - ; - - number - : NUMBER { $$ = { type: 'Number', value: parseFloat($1) }; } - | ADD NUMBER { $$ = { type: 'Number', value: parseFloat($2) }; } - | SUB NUMBER { $$ = { type: 'Number', value: parseFloat($2) * -1 }; } - ; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea991bf..da665e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,16 +8,28 @@ importers: .: dependencies: - postcss-selector-parser: - specifier: ^7.1.4 - version: 7.1.4 + '@csstools/css-tokenizer': + specifier: ^4.0.0 + version: 4.0.0 postcss-value-parser: specifier: ^4.2.0 version: 4.2.0 devDependencies: + '@csstools/css-calc': + specifier: ^3.2.0 + version: 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.4.1) + '@rmenke/css-tokenizer-tests': + specifier: ^1.2.0 + version: 1.2.0 + '@stryker-mutator/core': + specifier: ^9.6.1 + version: 9.6.1(@types/node@25.9.3) + '@stryker-mutator/typescript-checker': + specifier: ^9.6.1 + version: 9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.3))(typescript@6.0.3) '@types/node': specifier: ^25.9.3 version: 25.9.3 @@ -27,21 +39,197 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.4.1) - jison-gho: - specifier: 0.6.1-216 - version: 0.6.1-216 + eslint-plugin-sonarjs: + specifier: ^4.0.3 + version: 4.0.3(eslint@10.4.1) + fast-check: + specifier: ^4.7.0 + version: 4.7.0 postcss: specifier: ^8.5.15 version: 8.5.15 prettier: specifier: ^3.8.4 version: 3.8.4 + type-coverage: + specifier: ^2.29.7 + version: 2.29.7(typescript@6.0.3) typescript: specifier: ~6.0.3 version: 6.0.3 packages: + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -81,65 +269,227 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@gerhobbelt/ast-types@0.10.1-15': - resolution: {integrity: sha512-CXoPKXH9xqCiWza0S/4TFjXa7aS8GAA8gYenBzhMN5+VwWDFBd2QVUGESq75nRe+yxgUkzSFQvq6rtAuQLRouA==} - engines: {node: '>= 4.0'} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} - '@gerhobbelt/ast-types@0.9.13-4': - resolution: {integrity: sha512-V8UIj1XN6XOP014fPpecxEa7AlAB9kaTOB/wF9UbguuwIMWCHDmdA9i03JDK9zXyVDVaLWCYh42JK8F9f27AtA==} - engines: {node: '>= 4.0'} + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} - '@gerhobbelt/ast-util@0.6.1-4': - resolution: {integrity: sha512-NP7YZh7rR6CNiMLyKTF+qb2Epx0r5x/zKQ3Z14TgXl73YJurC8WkMkFM9nDj8cRXb6R+f+BEu4DqAvvYKMxbqg==} - engines: {node: '>= 4.0'} + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} - '@gerhobbelt/esprima@4.0.1-15': - resolution: {integrity: sha512-0VITWyCsgbRlNv0WjWfEszAHcgJL/iAQKSNfzU/uoJ6S7W/mgM8q4iWmzv7BDl4nmRpcYoSqW2B/BwXJNFzNMg==} - engines: {node: '>=4'} - hasBin: true + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} - '@gerhobbelt/json5@0.5.1-21': - resolution: {integrity: sha512-BwqwZb2iv7Iu4nwJwj1D0LKvnvKxMVXB+VgTsrwb+s36KY/xYaTmKbFq0MAoEGiMBcB8jz3/L/J6lBBdx5XqAw==} - hasBin: true + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} - '@gerhobbelt/linewrap@0.2.2-3': - resolution: {integrity: sha512-u2eUbXgNtqckBI4gxds/uiUNoytT+qIqpePmVDI5isW8A18uB3Qz1P+UxAHgFafGOZWJNrpR0IKnZhl7QhaUng==} - engines: {node: '>=4.0'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@gerhobbelt/nomnom@1.8.4-24': - resolution: {integrity: sha512-spzyz2vHd1BhYNSUMXjqJOwk4AjnOIzZz3cYCOryUCzMvlqz01/+SAPEy/pjT47CrOGdWd0JgemePjru1aLYgQ==} - engines: {node: '>=4.0'} + '@inquirer/checkbox@5.1.4': + resolution: {integrity: sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@gerhobbelt/recast@0.13.0-24': - resolution: {integrity: sha512-WqIAY+8RwgsgZHtJjeZJK3/w60uOMGOiW3Tcrm+gE31a3lcCjMnCgmYbauHLGCUYdRtepGS+jnr29ub3MFhKCg==} - engines: {node: '>= 4.0'} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@gerhobbelt/xregexp@3.2.0-22': - resolution: {integrity: sha512-TRu38Z67VxFSMrBP3z/ORiJVQqp56ulidZirbobtmJnVGBWLdo4GbHtihgIJFGieIZuk+LxmPkK45SY+SQsR3A==} + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} + '@inquirer/editor@5.1.1': + resolution: {integrity: sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} + '@inquirer/expand@5.0.13': + resolution: {integrity: sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} + '@inquirer/external-editor@3.0.0': + resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} + '@inquirer/input@5.0.12': + resolution: {integrity: sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.0.12': + resolution: {integrity: sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.12': + resolution: {integrity: sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.4.2': + resolution: {integrity: sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.2.8': + resolution: {integrity: sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.1.8': + resolution: {integrity: sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.1.4': + resolution: {integrity: sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@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'} + + '@rmenke/css-tokenizer-tests@1.2.0': + resolution: {integrity: sha512-XfdeXzW5QGc3inl69eid2FTLGY/514xs+VXQWlEzdUVm1QdU6MicU5S2hcEbHoC9WMzIMALTzxiZb49w+xJk0Q==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@stryker-mutator/api@9.6.1': + resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/core@9.6.1': + resolution: {integrity: sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==} + engines: {node: '>=20.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@9.6.1': + resolution: {integrity: sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==} + engines: {node: '>=20.0.0'} + + '@stryker-mutator/typescript-checker@9.6.1': + resolution: {integrity: sha512-dCFJDoFixFe7cbilsukb7a5jpn9JRSPef7/vgx+xfaf4gPf/neDVbci8E/YSvxmcFveuPHdeUxioocA1CKZqrg==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@stryker-mutator/core': 9.6.1 + typescript: '>=3.6' + + '@stryker-mutator/util@9.6.1': + resolution: {integrity: sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==} '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -154,73 +504,101 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ansi-regex@2.1.1: - resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} - engines: {node: '>=0.10.0'} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-regex@3.0.1: - resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} - engines: {node: '>=4'} + angular-html-parser@10.4.0: + resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} + engines: {node: '>= 14'} - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + baseline-browser-mapping@2.10.21: + resolution: {integrity: sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} - camelcase@4.1.0: - resolution: {integrity: sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==} - engines: {node: '>=4'} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} - chalk@2.1.0: - resolution: {integrity: sha512-LUHGS/dge4ujbXMJrnihYMcL4AoOweGnw9Tp3kQuqy1Kx5c1qKjqvMJZ6nVJPMWJtKCTN72ZogH3oeSO9g9rXQ==} - engines: {node: '>=4'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true - cliui@3.2.0: - resolution: {integrity: sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==} + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} - code-point-at@1.1.0: - resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} - engines: {node: '>=0.10.0'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} - core-js@2.5.3: - resolution: {integrity: sha512-1fhTiNuC8YWzCl567b1K2mQqRyHvQtRlEuNY31t837BFNd57oMvElJTsM5IrIooczeG/KvssBbJi2ZZASwyMIQ==} - deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + caniuse-lite@1.0.30001790: + resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} - cross-spawn@5.1.0: - resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -228,16 +606,40 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} @@ -249,6 +651,11 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-plugin-sonarjs@4.0.3: + resolution: {integrity: sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -291,30 +698,53 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - execa@0.7.0: - resolution: {integrity: sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==} - engines: {node: '>=4'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} - exit@0.1.2: - resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} - engines: {node: '>= 0.8.0'} + fast-check@4.7.0: + resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==} + engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - find-up@2.1.0: - resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} - engines: {node: '>=4'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} @@ -324,22 +754,65 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} - get-caller-file@1.0.3: - resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} - get-stream@3.0.0: - resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} - engines: {node: '>=4'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - has-flag@2.0.0: - resolution: {integrity: sha512-P+1n3MnwjR/Epg9BBo1KT8qbye2g2Ou4sFumihwt6I4tsUX7jnLcX4BTOSKg/B1ZrIYMN9FcEnG4x5a7NB8Eng==} + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ignore@5.3.2: @@ -350,84 +823,133 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - invert-kv@1.0.0: - resolution: {integrity: sha512-xgs2NH9AE66ucSq4cNG1nhSFghr5l6tdL15Pk+jl46bmmBapgoaY/AacXyaDznAqmGL99TiLSQgO/XazFSKYeQ==} - engines: {node: '>=0.10.0'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@1.0.0: - resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@2.0.0: - resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} - engines: {node: '>=4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-stream@1.1.0: - resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} - engines: {node: '>=0.10.0'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jison-gho@0.6.1-216: - resolution: {integrity: sha512-sBwC7puJgM1ECfBX0dbHpNo0v0+Dz4vXPoEpxUSVH8m3BiNRYLYtJeACC5vm8ACK5F4bQWrcHNacrqcVrYnWqA==} - engines: {node: '>=4.0'} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-rpc-2.0@1.7.1: + resolution: {integrity: sha512-JqZjhjAanbpkXIzFE7u8mE/iFblawwlXtONaCvRqI+pyABVz7B4M1EUNpyVW+dZjqgQ2L5HFmZCmOCgUKm00hg==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - lcid@1.0.0: - resolution: {integrity: sha512-YiGkH6EnGrDGqLMITnGjXtGmNtjoXw9SVUzcaos8RBi7Ps0VBylkq+vOcY9QE5poLasPCR849ucFUkl0UzUyOw==} - engines: {node: '>=0.10.0'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - locate-path@2.0.0: - resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} - engines: {node: '>=4'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} - mem@1.1.0: - resolution: {integrity: sha512-nOBDrc/wgpkd3X/JOhMqYR+/eLqlfLP4oQfoBA6QExIxEl+GU01oyEkwWyueyO8110pUKijtiHGhEmYoOn88oQ==} - engines: {node: '>=4'} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} - mimic-fn@1.2.0: - resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} - engines: {node: '>=4'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.7.3: + resolution: {integrity: sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==} + + mutation-testing-metrics@3.7.3: + resolution: {integrity: sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==} + + mutation-testing-report-schema@3.7.3: + resolution: {integrity: sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -436,68 +958,55 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - npm-run-path@2.0.2: - resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} - engines: {node: '>=4'} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - number-is-nan@1.0.1: - resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - os-locale@2.1.0: - resolution: {integrity: sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==} - engines: {node: '>=4'} - - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} - engines: {node: '>=4'} - - p-limit@1.3.0: - resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} - engines: {node: '>=4'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-locate@2.0.0: - resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} - engines: {node: '>=4'} - p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-try@1.0.0: - resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} - engines: {node: '>=4'} - - path-exists@3.0.0: - resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} - engines: {node: '>=4'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} - engines: {node: '>=4'} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -515,137 +1024,439 @@ packages: engines: {node: '>=14'} hasBin: true - private@0.1.7: - resolution: {integrity: sha512-YmFOCNzqPkis1UxGH6pr8zN4DLoFNcJPvrD+ZLr7aThaOpaHufbWy+UhCa6PM0XszYIWkcJZUg40eKHR5+w+8w==} - engines: {node: '>= 0.6'} - - private@0.1.8: - resolution: {integrity: sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==} - engines: {node: '>= 0.6'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} - pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - require-main-filename@1.0.1: - resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==} + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + 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==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + 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@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} - engines: {node: '>=0.10.0'} - shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} - string-width@1.0.2: - resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} - engines: {node: '>=0.10.0'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} - string-width@2.1.1: - resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} - engines: {node: '>=4'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} - strip-ansi@3.0.1: - resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} - engines: {node: '>=0.10.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} - strip-ansi@4.0.0: - resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} - engines: {node: '>=4'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true - strip-eof@1.0.0: - resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} - engines: {node: '>=0.10.0'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' - supports-color@4.5.0: - resolution: {integrity: sha512-ycQR/UbvI9xIlEdQT1TQqwoXtEldExbCEAJgRo5YXlmSKjv6ThHnP9/vwGa1gr19Gfw+LkFd7KqYMhzrRC5JYw==} - engines: {node: '>=4'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-coverage-core@2.29.7: + resolution: {integrity: sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==} + peerDependencies: + typescript: 2 || 3 || 4 || 5 + + type-coverage@2.29.7: + resolution: {integrity: sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==} + hasBin: true + + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + weapon-regex@1.3.6: + resolution: {integrity: sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==} + + 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'} - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color - wrap-ansi@2.1.0: - resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} - engines: {node: '>=0.10.0'} + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color - y18n@3.2.2: - resolution: {integrity: sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==} + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 - yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color - yargs-parser@8.1.0: - resolution: {integrity: sha512-yP+6QqN8BmrgW2ggLtTbdrOyBNSI7zBa4IykmiV5R1wl1JWNxQvWhMfMdmzIYtKU7oP3OOInY/tl2ov3BDjnJQ==} + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 - yargs@10.0.3: - resolution: {integrity: sha512-DqBpQ8NAUX4GyPP/ijDGHsJya4tYqLQrjPr95HNsr1YwL3+daCfvBwg7+gIC6IdJhR2kATh3hb61vjzMWEtjdw==} + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 -snapshots: + '@csstools/css-tokenizer@4.0.0': {} '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1)': dependencies: @@ -657,7 +1468,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.0 minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -681,57 +1492,245 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@gerhobbelt/ast-types@0.10.1-15': {} + '@humanfs/core@0.19.1': {} - '@gerhobbelt/ast-types@0.9.13-4': {} + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@2.0.5': {} + + '@inquirer/checkbox@5.1.4(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/ast-util@0.6.1-4': + '@inquirer/confirm@6.0.12(@types/node@25.9.3)': dependencies: - '@gerhobbelt/ast-types': 0.9.13-4 - private: 0.1.7 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/esprima@4.0.1-15': {} + '@inquirer/core@11.1.9(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.9.3) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/json5@0.5.1-21': + '@inquirer/editor@5.1.1(@types/node@25.9.3)': dependencies: - yargs: 10.0.3 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/external-editor': 3.0.0(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/linewrap@0.2.2-3': {} + '@inquirer/expand@5.0.13(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/nomnom@1.8.4-24': + '@inquirer/external-editor@3.0.0(@types/node@25.9.3)': dependencies: - '@gerhobbelt/linewrap': 0.2.2-3 - chalk: 2.1.0 - exit: 0.1.2 + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/figures@2.0.5': {} - '@gerhobbelt/recast@0.13.0-24': + '@inquirer/input@5.0.12(@types/node@25.9.3)': dependencies: - '@gerhobbelt/ast-types': 0.10.1-15 - '@gerhobbelt/esprima': 4.0.1-15 - core-js: 2.5.3 - private: 0.1.8 - source-map: 0.6.1 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@gerhobbelt/xregexp@3.2.0-22': {} + '@inquirer/number@4.0.12(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@humanfs/core@0.19.2': + '@inquirer/password@5.0.12(@types/node@25.9.3)': dependencies: - '@humanfs/types': 0.15.0 + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 + + '@inquirer/prompts@8.4.2(@types/node@25.9.3)': + dependencies: + '@inquirer/checkbox': 5.1.4(@types/node@25.9.3) + '@inquirer/confirm': 6.0.12(@types/node@25.9.3) + '@inquirer/editor': 5.1.1(@types/node@25.9.3) + '@inquirer/expand': 5.0.13(@types/node@25.9.3) + '@inquirer/input': 5.0.12(@types/node@25.9.3) + '@inquirer/number': 4.0.12(@types/node@25.9.3) + '@inquirer/password': 5.0.12(@types/node@25.9.3) + '@inquirer/rawlist': 5.2.8(@types/node@25.9.3) + '@inquirer/search': 4.1.8(@types/node@25.9.3) + '@inquirer/select': 5.1.4(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@humanfs/node@0.16.8': + '@inquirer/rawlist@5.2.8(@types/node@25.9.3)': dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@humanfs/types@0.15.0': {} + '@inquirer/search@4.1.8(@types/node@25.9.3)': + dependencies: + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@humanwhocodes/module-importer@1.0.1': {} + '@inquirer/select@5.1.4(@types/node@25.9.3)': + dependencies: + '@inquirer/ansi': 2.0.5 + '@inquirer/core': 11.1.9(@types/node@25.9.3) + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@25.9.3) + optionalDependencies: + '@types/node': 25.9.3 - '@humanwhocodes/retry@0.4.3': {} + '@inquirer/type@4.0.5(@types/node@25.9.3)': + optionalDependencies: + '@types/node': 25.9.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@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.1 + + '@rmenke/css-tokenizer-tests@1.2.0': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@stryker-mutator/api@9.6.1': + dependencies: + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@9.6.1(@types/node@25.9.3)': + dependencies: + '@inquirer/prompts': 8.4.2(@types/node@25.9.3) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/instrumenter': 9.6.1 + '@stryker-mutator/util': 9.6.1 + ajv: 8.18.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.7.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.5 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.7.3 + mutation-testing-metrics: 3.7.3 + mutation-testing-report-schema: 3.7.3 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.7.4 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + - supports-color + + '@stryker-mutator/instrumenter@9.6.1': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/util': 9.6.1 + angular-html-parser: 10.4.0 + semver: 7.7.4 + tslib: 2.8.1 + weapon-regex: 1.3.6 + transitivePeerDependencies: + - supports-color + + '@stryker-mutator/typescript-checker@9.6.1(@stryker-mutator/core@9.6.1(@types/node@25.9.3))(typescript@6.0.3)': + dependencies: + '@stryker-mutator/api': 9.6.1 + '@stryker-mutator/core': 9.6.1(@types/node@25.9.3) + '@stryker-mutator/util': 9.6.1 + semver: 7.7.4 + typescript: 6.0.3 + + '@stryker-mutator/util@9.6.1': {} '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.6': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -740,11 +1739,11 @@ snapshots: dependencies: undici-types: 7.24.6 - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 - acorn@8.16.0: {} + acorn@8.17.0: {} ajv@6.15.0: dependencies: @@ -753,49 +1752,75 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-regex@2.1.1: {} + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 - ansi-regex@3.0.1: {} + angular-html-parser@10.4.0: {} - ansi-styles@3.2.1: + ansi-styles@4.3.0: dependencies: - color-convert: 1.9.3 + color-convert: 2.0.1 balanced-match@4.0.4: {} - brace-expansion@5.0.6: + baseline-browser-mapping@2.10.21: {} + + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 - camelcase@4.1.0: {} + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.21 + caniuse-lite: 1.0.30001790 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + builtin-modules@3.3.0: {} + + bytes@3.1.2: {} - chalk@2.1.0: + call-bind-apply-helpers@1.0.2: dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 4.5.0 + es-errors: 1.3.0 + function-bind: 1.1.2 - cliui@3.2.0: + call-bound@1.0.4: dependencies: - string-width: 1.0.2 - strip-ansi: 3.0.1 - wrap-ansi: 2.1.0 + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 - code-point-at@1.1.0: {} + caniuse-lite@1.0.30001790: {} - color-convert@1.9.3: + chalk@4.1.2: dependencies: - color-name: 1.1.3 + ansi-styles: 4.3.0 + supports-color: 7.2.0 - color-name@1.1.3: {} + chalk@5.6.2: {} - core-js@2.5.3: {} + chardet@2.1.1: {} - cross-spawn@5.1.0: + cli-width@4.1.0: {} + + color-convert@2.0.1: dependencies: - lru-cache: 4.1.5 - shebang-command: 1.2.0 - which: 1.3.1 + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@14.0.3: {} + + convert-source-map@2.0.0: {} cross-spawn@7.0.6: dependencies: @@ -803,17 +1828,38 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cssesc@3.0.0: {} - - debug@4.4.3: + debug@4.4.0: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - deep-is@0.1.4: {} - escape-string-regexp@1.0.5: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + diff-match-patch@1.0.5: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.344: {} + + emoji-regex@10.6.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -821,6 +1867,22 @@ snapshots: dependencies: eslint: 10.4.1 + eslint-plugin-sonarjs@4.0.3(eslint@10.4.1): + dependencies: + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 10.4.1 + functional-red-black-tree: 1.0.1 + globals: 17.5.0 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.2.5 + scslre: 0.3.0 + semver: 7.7.4 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -840,13 +1902,13 @@ snapshots: '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 - '@humanfs/node': 0.16.8 + '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 + '@types/estree': 1.0.6 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.0 escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -869,8 +1931,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -885,31 +1947,66 @@ snapshots: esutils@2.0.3: {} - execa@0.7.0: + execa@9.6.1: dependencies: - cross-spawn: 5.1.0 - get-stream: 3.0.0 - is-stream: 1.1.0 - npm-run-path: 2.0.2 - p-finally: 1.0.0 - signal-exit: 3.0.7 - strip-eof: 1.0.0 + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 - exit@0.1.2: {} + fast-check@4.7.0: + dependencies: + pure-rand: 8.4.0 fast-deep-equal@3.1.3: {} + 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 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.0: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 - find-up@2.1.0: + fill-range@7.1.1: dependencies: - locate-path: 2.0.0 + to-regex-range: 5.0.1 find-up@5.0.0: dependencies: @@ -918,105 +2015,176 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.3.2 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.3.2: {} + + function-bind@1.1.2: {} + + functional-red-black-tree@1.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 - get-caller-file@1.0.3: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 - get-stream@3.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 glob-parent@6.0.2: dependencies: is-glob: 4.0.3 - has-flag@2.0.0: {} + globals@17.5.0: {} - ignore@5.3.2: {} + gopd@1.2.0: {} - imurmurhash@0.1.4: {} + has-flag@4.0.0: {} - invert-kv@1.0.0: {} + has-symbols@1.1.0: {} - is-extglob@2.1.1: {} + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + human-signals@8.0.1: {} - is-fullwidth-code-point@1.0.0: + iconv-lite@0.7.2: dependencies: - number-is-nan: 1.0.1 + safer-buffer: 2.1.2 + + ignore@5.3.2: {} - is-fullwidth-code-point@2.0.0: {} + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - is-stream@1.1.0: {} + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} isexe@2.0.0: {} - jison-gho@0.6.1-216: - dependencies: - '@gerhobbelt/ast-util': 0.6.1-4 - '@gerhobbelt/json5': 0.5.1-21 - '@gerhobbelt/nomnom': 1.8.4-24 - '@gerhobbelt/recast': 0.13.0-24 - '@gerhobbelt/xregexp': 3.2.0-22 + js-md4@0.3.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} json-buffer@3.0.1: {} + json-rpc-2.0@1.7.1: {} + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + + jsx-ast-utils-x@0.1.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 - lcid@1.0.0: - dependencies: - invert-kv: 1.0.0 - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - locate-path@2.0.0: - dependencies: - p-locate: 2.0.0 - path-exists: 3.0.0 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 - lru-cache@4.1.5: + lodash.groupby@4.6.0: {} + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 + yallist: 3.1.1 - mem@1.1.0: + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: dependencies: - mimic-fn: 1.2.0 + braces: 3.0.3 + picomatch: 2.3.2 - mimic-fn@1.2.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.5 + + minimist@1.2.8: {} ms@2.1.3: {} + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.3.6 + + mutation-testing-elements@3.7.3: {} + + mutation-testing-metrics@3.7.3: + dependencies: + mutation-testing-report-schema: 3.7.3 + + mutation-testing-report-schema@3.7.3: {} + + mute-stream@3.0.0: {} + nanoid@3.3.12: {} natural-compare@1.4.0: {} - npm-run-path@2.0.2: + node-releases@2.0.38: {} + + normalize-path@3.0.0: {} + + npm-run-path@6.0.0: dependencies: - path-key: 2.0.1 + path-key: 4.0.0 + unicorn-magic: 0.3.0 - number-is-nan@1.0.1: {} + object-inspect@1.13.4: {} optionator@0.9.4: dependencies: @@ -1027,46 +2195,25 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - os-locale@2.1.0: - dependencies: - execa: 0.7.0 - lcid: 1.0.0 - mem: 1.1.0 - - p-finally@1.0.0: {} - - p-limit@1.3.0: - dependencies: - p-try: 1.0.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-locate@2.0.0: - dependencies: - p-limit: 1.3.0 - p-locate@5.0.0: dependencies: p-limit: 3.1.0 - p-try@1.0.0: {} - - path-exists@3.0.0: {} + parse-ms@4.0.0: {} path-exists@4.0.0: {} - path-key@2.0.1: {} - path-key@3.1.1: {} + path-key@4.0.0: {} + picocolors@1.1.1: {} - postcss-selector-parser@7.1.4: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 + picomatch@2.3.2: {} postcss-value-parser@4.2.0: {} @@ -1080,82 +2227,172 @@ snapshots: prettier@3.8.4: {} - private@0.1.7: {} - - private@0.1.8: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 - pseudomap@1.0.2: {} + progress@2.0.3: {} punycode@2.3.1: {} - require-directory@2.1.1: {} + pure-rand@8.4.0: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + require-from-string@2.0.2: {} + + reusify@1.1.0: {} - require-main-filename@1.0.1: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 - set-blocking@2.0.0: {} + safer-buffer@2.1.2: {} - shebang-command@1.2.0: + scslre@0.3.0: dependencies: - shebang-regex: 1.0.0 + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + semver@6.3.1: {} + + semver@7.7.4: {} shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 - shebang-regex@1.0.0: {} - shebang-regex@3.0.0: {} - signal-exit@3.0.7: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 - source-map-js@1.2.1: {} + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 - source-map@0.6.1: {} + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 - string-width@1.0.2: + side-channel@1.1.0: dependencies: - code-point-at: 1.1.0 - is-fullwidth-code-point: 1.0.0 - strip-ansi: 3.0.1 + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} - string-width@2.1.1: + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + strip-final-newline@4.0.0: {} + + supports-color@7.2.0: dependencies: - is-fullwidth-code-point: 2.0.0 - strip-ansi: 4.0.0 + has-flag: 4.0.0 - strip-ansi@3.0.1: + to-regex-range@5.0.1: dependencies: - ansi-regex: 2.1.1 + is-number: 7.0.0 - strip-ansi@4.0.0: + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - ansi-regex: 3.0.1 + typescript: 6.0.3 + + tslib@1.14.1: {} - strip-eof@1.0.0: {} + tslib@2.8.1: {} - supports-color@4.5.0: + tsutils@3.21.0(typescript@6.0.3): dependencies: - has-flag: 2.0.0 + tslib: 1.14.1 + typescript: 6.0.3 + + tunnel@0.0.6: {} type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-coverage-core@2.29.7(typescript@6.0.3): + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + normalize-path: 3.0.0 + tslib: 2.8.1 + tsutils: 3.21.0(typescript@6.0.3) + typescript: 6.0.3 + + type-coverage@2.29.7(typescript@6.0.3): + dependencies: + chalk: 4.1.2 + minimist: 1.2.8 + type-coverage-core: 2.29.7(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + typed-inject@5.0.0: {} + + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.15.1 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript@6.0.3: {} + underscore@1.13.8: {} + undici-types@7.24.6: {} + unicorn-magic@0.3.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} - - which-module@2.0.1: {} - - which@1.3.1: - dependencies: - isexe: 2.0.0 + weapon-regex@1.3.6: {} which@2.0.2: dependencies: @@ -1163,32 +2400,10 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@2.1.0: - dependencies: - string-width: 1.0.2 - strip-ansi: 3.0.1 - - y18n@3.2.2: {} - - yallist@2.1.2: {} + yallist@3.1.1: {} - yargs-parser@8.1.0: - dependencies: - camelcase: 4.1.0 + yocto-queue@0.1.0: {} - yargs@10.0.3: - dependencies: - cliui: 3.2.0 - decamelize: 1.2.0 - find-up: 2.1.0 - get-caller-file: 1.0.3 - os-locale: 2.1.0 - require-directory: 2.1.1 - require-main-filename: 1.0.1 - set-blocking: 2.0.0 - string-width: 2.1.1 - which-module: 2.0.1 - y18n: 3.2.2 - yargs-parser: 8.1.0 + yoctocolors@2.1.2: {} - yocto-queue@0.1.0: {} + zod@4.3.6: {} diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs new file mode 100644 index 0000000..e70b328 --- /dev/null +++ b/scripts/benchmark.mjs @@ -0,0 +1,77 @@ +// Benchmark: postcss-calc (pratt) vs @csstools/css-calc on the harvested +// real-world corpus. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tokenize } from '../src/lib/tokenizer.js'; +import { parse } from '../src/lib/parser.js'; +import { simplify } from '../src/lib/simplify.js'; +import { serialize } from '../src/lib/serialize.js'; +import { calc as csstoolsCalc } from '@csstools/css-calc'; +const ROOT = dirname(fileURLToPath(import.meta.url)); +const CORPUS = join(ROOT, '..', 'test/corpus/github-pure.txt'); +const corpus = readFileSync(CORPUS, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +const ours = (s) => { + try { + return serialize(simplify(parse(tokenize(s))), { precision: false }); + } catch { + return null; + } +}; +const theirs = (s) => { + try { + const r = csstoolsCalc(s); + return typeof r === 'string' ? r : null; + } catch { + return null; + } +}; +function bench(name, fn) { + for (let i = 0; i < 3; i++) for (const s of corpus) fn(s); + const ITERS = 5; + let okCount = 0; + let threwCount = 0; + const start = performance.now(); + for (let it = 0; it < ITERS; it++) { + okCount = 0; + threwCount = 0; + for (const s of corpus) { + const r = fn(s); + if (r === null) threwCount++; + else okCount++; + } + } + const totalMs = (performance.now() - start) / ITERS; + return { name, totalMs, okCount, threwCount }; +} +console.log( + `Corpus: ${corpus.length.toLocaleString()} real-world calc() expressions` +); +console.log('Running 3 warmup + 5 measured iterations each…\n'); +const a = bench('postcss-calc (pratt)', ours); +const b = bench('@csstools/css-calc ', theirs); +const fmt = (s) => { + const perCallUs = (s.totalMs * 1000) / corpus.length; + return [ + s.name, + `total ${s.totalMs.toFixed(1).padStart(6)} ms`, + `${perCallUs.toFixed(2).padStart(5)} µs/expr`, + `accepted ${s.okCount.toString().padStart(5)}`, + `threw ${s.threwCount.toString().padStart(4)}`, + ].join(' '); +}; +console.log(fmt(a)); +console.log(fmt(b)); +const ratio = b.totalMs / a.totalMs; +const speedLabel = + ratio >= 1 + ? `${ratio.toFixed(2)}× faster` + : `${(1 / ratio).toFixed(2)}× slower`; +console.log(`\nSpeed: postcss-calc is ${speedLabel} than csstools.`); +console.log( + `Coverage: postcss-calc accepts ${a.okCount}, csstools accepts ${b.okCount} ` + + `(diff ${a.okCount - b.okCount > 0 ? '+' : ''}${a.okCount - b.okCount}).` +); diff --git a/scripts/harvest-github.mjs b/scripts/harvest-github.mjs new file mode 100644 index 0000000..8372e58 --- /dev/null +++ b/scripts/harvest-github.mjs @@ -0,0 +1,379 @@ +// Harvest calc() expressions from public GitHub via `gh search code`. +// Phase 1: discover paths via diversifying queries (bypasses GH's +// 1000-result-per-query cap). Phase 2: fetch raw content and extract +// paren-balanced calc() bodies. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..'); +const OUT_DIR = join(ROOT, 'test/corpus/github'); +const FILES_DIR = join(OUT_DIR, 'files'); +const EXPR_FILE = join(OUT_DIR, 'expressions.txt'); +const STATE_FILE = join(OUT_DIR, '.harvest-state.json'); +const args = new Set(process.argv.slice(2)); +const PILOT = args.has('--pilot'); +const SKIP_FETCH = args.has('--skip-fetch'); +const PILOT_QUERIES = [ + 'calc(100%', + 'calc(var(', + 'calc(100vh', + 'calc(min(', + 'calc(clamp(', +]; +const FULL_QUERIES = [ + 'calc(100%', + 'calc(50%', + 'calc(100vh', + 'calc(100vw', + 'calc(1px', + 'calc(2px', + 'calc(1em', + 'calc(1rem', + 'calc(0.5rem', + 'calc(1.5rem', + 'calc(1deg', + 'calc(1turn', + 'calc(1s', + 'calc(1fr', + 'calc(1ch', + 'calc(var(', + 'calc( var(', + 'calc(100% -', + 'calc(100% +', + 'calc(100% /', + 'calc(100% *', + 'calc(min(', + 'calc(max(', + 'calc(clamp(', + 'calc(round(', + 'calc(mod(', + 'calc(abs(', + 'calc(sin(', + 'calc(cos(', + 'calc(pow(', + 'calc(sqrt(', + 'calc(hypot(', + 'margin: calc(', + 'padding: calc(', + 'width: calc(', + 'height: calc(', + 'top: calc(', + 'left: calc(', + 'transform: translate(calc(', + 'grid-template-columns: calc(', + 'calc((', + 'calc(((', + 'calc( (', + 'calc( ((', + 'calc(calc(', + 'calc( calc(', + 'calc(0', + 'calc(1', + 'calc(2', + 'calc(3', + 'calc(4', + 'calc(5', + 'calc(6', + 'calc(7', + 'calc(8', + 'calc(9', + 'calc(.5', + 'calc(.25', + 'calc(-.', + 'calc(-1', + 'calc(-2', + 'calc(env(', + 'calc(attr(', +]; +const LANGUAGES = ['css', 'scss', 'less']; +const QUERIES = PILOT ? PILOT_QUERIES : FULL_QUERIES; +mkdirSync(FILES_DIR, { recursive: true }); +function loadState() { + if (existsSync(STATE_FILE)) { + try { + return JSON.parse(readFileSync(STATE_FILE, 'utf8')); + } catch { + /* fall through */ + } + } + return { files: {}, expressions: [] }; +} +function saveState(s) { + writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); +} +const state = loadState(); +function gh(argv) { + // gh CLI is intentionally PATH-resolved — this is a CI/dev tool script. + // eslint-disable-next-line sonarjs/no-os-command-from-path + return execFileSync('gh', argv, { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} +// GH code_search: 10 req/min. Pace at 7s; on 403 sleep until reset and retry. +const MIN_SEARCH_GAP_MS = 7000; +let lastSearchAt = 0; +async function paceSearch() { + const elapsed = Date.now() - lastSearchAt; + if (elapsed < MIN_SEARCH_GAP_MS) await delay(MIN_SEARCH_GAP_MS - elapsed); + lastSearchAt = Date.now(); +} +async function waitForSearchReset() { + try { + const j = JSON.parse(gh(['api', '/rate_limit'])); + const reset = j.resources?.code_search?.reset; + if (reset) { + const waitMs = Math.max(0, reset * 1000 - Date.now()) + 2000; + process.stderr.write( + ` rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` + ); + await delay(waitMs); + } else { + await delay(60_000); + } + } catch { + await delay(60_000); + } +} +async function searchCode(query, language, limit = 100) { + // Wrap multi-word queries in literal quotes so GH treats them as a phrase. + const phrase = /[\s+\-/*:]/.test(query) ? `"${query}"` : query; + const argv = [ + 'search', + 'code', + phrase, + '--language', + language, + '--limit', + String(limit), + '--json', + 'repository,path', + ]; + for (let attempt = 0; attempt < 2; attempt++) { + await paceSearch(); + try { + return JSON.parse(gh(argv)); + } catch (err) { + const msg = err.message ?? ''; + if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { + await waitForSearchReset(); + continue; + } + process.stderr.write( + ` ! search failed for ${JSON.stringify(query)} lang=${language}: ${msg.split('\n')[0]}\n` + ); + return []; + } + } + return []; +} +// Pace fetches under 5000/hr core-API limit. 900ms ≈ 4000/hr with headroom. +const MIN_FETCH_GAP_MS = 900; +let lastFetchAt = 0; +async function paceFetch() { + const elapsed = Date.now() - lastFetchAt; + if (elapsed < MIN_FETCH_GAP_MS) await delay(MIN_FETCH_GAP_MS - elapsed); + lastFetchAt = Date.now(); +} +async function waitForCoreReset() { + try { + const j = JSON.parse(gh(['api', '/rate_limit'])); + const reset = j.resources?.core?.reset; + if (reset) { + const waitMs = Math.max(0, reset * 1000 - Date.now()) + 5000; + process.stderr.write( + ` core rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` + ); + await delay(waitMs); + } else { + await delay(60_000); + } + } catch { + await delay(60_000); + } +} +async function fetchRaw(owner, repo, path) { + const argv = [ + 'api', + '-H', + 'Accept: application/vnd.github.raw', + `/repos/${owner}/${repo}/contents/${encodeURI(path)}`, + ]; + for (let attempt = 0; attempt < 3; attempt++) { + await paceFetch(); + try { + return gh(argv); + } catch (err) { + const msg = err.message ?? ''; + if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { + await waitForCoreReset(); + continue; + } + process.stderr.write( + ` ! fetch failed ${owner}/${repo}:${path}: ${msg.split('\n')[0]}\n` + ); + return null; + } + } + return null; +} +// Strip /* */ comments and quoted strings (replace with spaces to keep offsets). +function sanitize(src) { + let out = ''; + let i = 0; + while (i < src.length) { + const c = src[i]; + const n = src[i + 1]; + if (c === '/' && n === '*') { + const end = src.indexOf('*/', i + 2); + if (end === -1) { + out += ' '.repeat(src.length - i); + break; + } + out += ' '.repeat(end + 2 - i); + i = end + 2; + continue; + } + if (c === '"' || c === "'") { + const quote = c; + out += ' '; + i++; + while (i < src.length && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < src.length) { + out += ' '; + i += 2; + continue; + } + out += ' '; + i++; + } + if (i < src.length) { + out += ' '; + i++; + } + continue; + } + out += c; + i++; + } + return out; +} +const CALC_RE = /(?:^|[^\w-])(?:-(?:webkit|moz|ms|o)-)?calc\(/gi; +function extractCalcs(src) { + const sanitized = sanitize(src); + const results = []; + let m; + CALC_RE.lastIndex = 0; + while ((m = CALC_RE.exec(sanitized)) !== null) { + const matchEnd = m.index + m[0].length; + const openParen = matchEnd - 1; + const before = sanitized.slice(0, openParen).toLowerCase(); + const calcStart = before.lastIndexOf('calc'); + if (calcStart === -1) continue; + let depth = 1; + let j = openParen + 1; + while (j < sanitized.length && depth > 0) { + const ch = sanitized[j]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + j++; + } + if (depth !== 0) continue; + const expr = src.slice(calcStart, j); + const flat = expr.replace(/\s+/g, ' ').trim(); + if (flat.length > 2 && flat.length < 4096) results.push(flat); + // Continue scanning after the open paren so nested calc()s also match. + CALC_RE.lastIndex = matchEnd; + } + return results; +} +function safeName(owner, repo, path) { + return `${owner}__${repo}__${path}` + .replace(/[/\\]/g, '_') + .replace(/[^\w.-]/g, '_'); +} +// Phase 1: discover paths. +const discovered = new Map(); +let queryCount = 0; +for (const query of QUERIES) { + for (const lang of LANGUAGES) { + queryCount++; + process.stderr.write( + `[${queryCount}/${QUERIES.length * LANGUAGES.length}] search ${JSON.stringify(query)} lang=${lang}\n` + ); + const limit = PILOT ? 30 : 100; + const results = await searchCode(query, lang, limit); + let added = 0; + for (const r of results) { + const nwo = r.repository?.nameWithOwner; + const path = r.path; + if (!nwo || !path) continue; + const [owner, repo] = nwo.split('/'); + if (!owner || !repo) continue; + const key = `${nwo}:${path}`; + if (discovered.has(key)) continue; + discovered.set(key, { owner, repo, path }); + added++; + } + process.stderr.write(` +${added} new (${discovered.size} total)\n`); + } +} +process.stderr.write( + `\nDiscovered ${discovered.size} unique files across ${queryCount} searches.\n\n` +); +if (SKIP_FETCH) { + saveState(state); + process.exit(0); +} +// Phase 2: fetch + extract. +const expressions = new Set(state.expressions); +let fetched = 0; +let skipped = 0; +let failed = 0; +let calcCount = 0; +const total = discovered.size; +let idx = 0; +for (const [key, { owner, repo, path }] of discovered) { + idx++; + if (state.files[key]) { + skipped++; + continue; + } + process.stderr.write(`[${idx}/${total}] fetch ${owner}/${repo}:${path}\n`); + const raw = await fetchRaw(owner, repo, path); + if (!raw) { + failed++; + continue; + } + fetched++; + const fname = safeName(owner, repo, path); + const ext = + /\.(css|scss|less|stylus|styl)$/i.exec(path)?.[1]?.toLowerCase() ?? 'css'; + writeFileSync(join(FILES_DIR, `${fname}.${ext}`), raw); + const calcs = extractCalcs(raw); + for (const c of calcs) expressions.add(c); + calcCount += calcs.length; + state.files[key] = { calcs: calcs.length, bytes: raw.length }; + if (fetched % 25 === 0) { + state.expressions = [...expressions]; + saveState(state); + writeFileSync( + EXPR_FILE, + [...expressions].sort((a, b) => a.localeCompare(b)).join('\n') + '\n' + ); + } +} +state.expressions = [...expressions]; +saveState(state); +writeFileSync( + EXPR_FILE, + [...expressions].sort((a, b) => a.localeCompare(b)).join('\n') + '\n' +); +process.stderr.write( + `\nDone. fetched=${fetched} skipped=${skipped} failed=${failed} calc-occurrences=${calcCount} unique-expressions=${expressions.size}\n` +); +process.stderr.write(`Files: ${FILES_DIR}\nExpressions: ${EXPR_FILE}\n`); diff --git a/scripts/randomizer.mjs b/scripts/randomizer.mjs new file mode 100644 index 0000000..d4e88e6 --- /dev/null +++ b/scripts/randomizer.mjs @@ -0,0 +1,249 @@ +// Long-running differential generator. Hammers calc() inputs against +// @csstools/css-calc, ratcheting depth and bucketing inputs by token +// count. Logs divergences/throws to reports/randomizer-finds.jsonl. +// +// Env: RANDOMIZER_MODE (complex|astArb|astArbDegen, default complex), +// RANDOMIZER_DEPTH_MIN (3), RANDOMIZER_DEPTH_MAX (6), +// RANDOMIZER_BATCH (500), RANDOMIZER_LOG (path). +import { mkdirSync, appendFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fc from 'fast-check'; +import { calc as csstoolsCalc } from '@csstools/css-calc'; +import { tokenize } from '../src/lib/tokenizer.js'; +import { parse } from '../src/lib/parser.js'; +import { simplify } from '../src/lib/simplify.js'; +import { serialize } from '../src/lib/serialize.js'; +import { + astArb, + astArbWithDegenerate, + astToCalc, +} from '../test/helpers/arbitraries.mjs'; +import { mkSum, mkProduct } from '../src/lib/node.js'; +const ROOT = dirname(fileURLToPath(import.meta.url)); +const REPORT_DEFAULT = join(ROOT, '..', 'reports', 'randomizer-finds.jsonl'); +const DEPTH_MIN = Number(process.env.RANDOMIZER_DEPTH_MIN ?? 3); +const DEPTH_MAX = Number(process.env.RANDOMIZER_DEPTH_MAX ?? 6); +const BATCH = Number(process.env.RANDOMIZER_BATCH ?? 500); +const LOG_PATH = process.env.RANDOMIZER_LOG ?? REPORT_DEFAULT; +const MODE = process.env.RANDOMIZER_MODE ?? 'complex'; +const COMPARE_PRECISION = 9; +function ourOut(input) { + try { + return serialize(simplify(parse(tokenize(input))), { + precision: COMPARE_PRECISION, + }); + } catch { + return null; + } +} +function theirOut(input) { + try { + const r = csstoolsCalc(input); + return typeof r === 'string' ? r : null; + } catch { + return null; + } +} +function compare(input) { + const ours = ourOut(input); + const theirs = theirOut(input); + if (ours === null && theirs === null) return { kind: 'both-threw' }; + if (ours === null) return { kind: 'we-threw', theirs: theirs }; + if (theirs === null) return { kind: 'they-threw', ours }; + if (ours === theirs) return { kind: 'agree' }; + // Re-feed csstools' output through our pipeline to absorb cosmetic noise. + const ct = ourOut(theirs); + if (ct !== null && ours === ct) return { kind: 'agree' }; + return { kind: 'mismatch', ours, theirs }; +} +// Token-count buckets. `max` is exclusive; bigger lands in 'huge'. +const BUCKETS = [ + { label: 'tiny', max: 20 }, + { label: 'small', max: 50 }, + { label: 'med', max: 150 }, + { label: 'big', max: 500 }, + { label: 'huge', max: Infinity }, +]; +function bucketOf(tokenCount) { + for (const b of BUCKETS) if (tokenCount < b.max) return b.label; + return 'huge'; +} +function countTokens(input) { + try { + // -1 drops the trailing 'eof' token. + return Math.max(0, tokenize(input).length - 1); + } catch { + return 0; + } +} +function emptyStats() { + return { + total: 0, + agree: 0, + bothThrew: 0, + weThrew: 0, + theyThrew: 0, + mismatch: 0, + byBucket: new Map(BUCKETS.map((b) => [b.label, 0])), + byDepth: new Map(), + start: Date.now(), + }; +} +function logFind(record) { + appendFileSync(LOG_PATH, JSON.stringify(record) + '\n', 'utf8'); +} +function fmtRate(n, ms) { + if (ms <= 0) return '0'; + const rate = (n * 1000) / ms; + return rate >= 1000 + ? `${(rate / 1000).toFixed(1)}k/s` + : `${rate.toFixed(0)}/s`; +} +function printStatus(stats, currentDepth) { + const ms = Date.now() - stats.start; + const buckets = [...stats.byBucket.entries()] + .map(([k, v]) => `${k}=${v}`) + .join(' '); + const finds = `weThrew=${stats.weThrew} theyThrew=${stats.theyThrew} mismatch=${stats.mismatch}`; + process.stdout.write( + `[${(ms / 1000).toFixed(0)}s] depth=${currentDepth} ` + + `n=${stats.total} ${fmtRate(stats.total, ms)} ` + + `agree=${stats.agree} bothThrew=${stats.bothThrew} ` + + `${finds} buckets[${buckets}]\n` + ); +} +// Cycle depth_min..depth_max so simpler buckets stay in rotation; they +// find the cheapest bugs. +function depthForTick(tick) { + const span = DEPTH_MAX - DEPTH_MIN + 1; + return DEPTH_MIN + (tick % span); +} +// Pure-number leaves (small ints) so every subtree is a valid +// regardless of how it's wrapped. Lets math Calls compose freely without +// type-invalid args. The hunt is structural complexity, not numeric stress. +const SMALL_INT_LEAF = fc + .integer({ min: -9, max: 9 }) + .map((value) => ({ type: 'Num', value })); +const sign1 = fc.constantFrom(1, -1); +const complexAstArb = fc.memo((depth) => { + if (depth <= 1) return SMALL_INT_LEAF; + const sub = complexAstArb(depth - 1); + const sumOf = fc + .array(fc.tuple(sign1, sub), { minLength: 2, maxLength: 6 }) + .map((pairs) => mkSum(pairs.map(([sign, node]) => ({ sign, node })))); + const productOf = fc + .array(fc.tuple(sign1, sub), { minLength: 2, maxLength: 6 }) + .map((pairs) => + mkProduct(pairs.map(([exp, node]) => ({ exponent: exp, node }))) + ); + const minMaxCall = fc + .tuple( + fc.constantFrom('min', 'max'), + fc.array(sub, { minLength: 2, maxLength: 5 }) + ) + .map(([name, args]) => ({ type: 'Call', name, args })); + const clampCall = fc.tuple(sub, sub, sub).map(([lo, val, hi]) => ({ + type: 'Call', + name: 'clamp', + args: [lo, val, hi], + })); + const unaryCall = fc + .tuple(fc.constantFrom('abs', 'sign'), sub) + .map(([name, arg]) => ({ type: 'Call', name, args: [arg] })); + const binaryCall = fc + .tuple(fc.constantFrom('round', 'mod', 'rem'), sub, sub) + .map(([name, a, b]) => ({ type: 'Call', name, args: [a, b] })); + // Small int exponent keeps results finite over deep Sum/Product bases. + const powCall = fc + .tuple(sub, fc.integer({ min: 0, max: 4 })) + .map(([base, exp]) => ({ + type: 'Call', + name: 'pow', + args: [base, { type: 'Num', value: exp }], + })); + return fc.oneof( + { weight: 1, arbitrary: SMALL_INT_LEAF }, + { weight: 3, arbitrary: sumOf }, + { weight: 3, arbitrary: productOf }, + { weight: 2, arbitrary: minMaxCall }, + { weight: 1, arbitrary: clampCall }, + { weight: 1, arbitrary: unaryCall }, + { weight: 1, arbitrary: binaryCall }, + { weight: 1, arbitrary: powCall } + ); +}); +function generatorFor(depth) { + switch (MODE) { + case 'astArb': + return astArb(depth); + case 'astArbDegen': + return astArbWithDegenerate(depth); + case 'complex': + default: + return complexAstArb(depth); + } +} +function recordOutcome(stats, outcome, depth, tokens, bucket, input) { + switch (outcome.kind) { + case 'agree': + stats.agree++; + return; + case 'both-threw': + stats.bothThrew++; + return; + case 'we-threw': + stats.weThrew++; + break; + case 'they-threw': + stats.theyThrew++; + break; + case 'mismatch': + stats.mismatch++; + break; + } + logFind({ + ts: new Date().toISOString(), + depth, + tokens, + bucket, + input, + ...outcome, + }); +} +function main() { + mkdirSync(dirname(LOG_PATH), { recursive: true }); + const stats = emptyStats(); + let tick = 0; + let lastStatus = Date.now(); + const stop = () => { + printStatus(stats, depthForTick(tick)); + process.stdout.write(`\nLog: ${LOG_PATH}\n`); + process.exit(0); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + process.stdout.write( + `randomizer: mode=${MODE}, depth ${DEPTH_MIN}..${DEPTH_MAX}, ` + + `batch ${BATCH}, log=${LOG_PATH}\n` + ); + while (true) { + const depth = depthForTick(tick++); + const samples = fc.sample(generatorFor(depth), BATCH); + for (const ast of samples) { + stats.total++; + stats.byDepth.set(depth, (stats.byDepth.get(depth) ?? 0) + 1); + const input = astToCalc(ast); + const tokens = countTokens(input); + const bucket = bucketOf(tokens); + stats.byBucket.set(bucket, (stats.byBucket.get(bucket) ?? 0) + 1); + recordOutcome(stats, compare(input), depth, tokens, bucket, input); + } + const now = Date.now(); + if (now - lastStatus >= 2000) { + printStatus(stats, depth); + lastStatus = now; + } + } +} +main(); diff --git a/scripts/show-divergences.mjs b/scripts/show-divergences.mjs new file mode 100644 index 0000000..4967055 --- /dev/null +++ b/scripts/show-divergences.mjs @@ -0,0 +1,67 @@ +// Bucket github-pure corpus divergences against @csstools/css-calc. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tokenize } from '../src/lib/tokenizer.js'; +import { parse } from '../src/lib/parser.js'; +import { simplify } from '../src/lib/simplify.js'; +import { serialize } from '../src/lib/serialize.js'; +import { calc as csstoolsCalc } from '@csstools/css-calc'; +const ROOT = dirname(fileURLToPath(import.meta.url)); +const CORPUS = join(ROOT, '..', 'test/corpus/github-pure.txt'); +const lines = readFileSync(CORPUS, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +const ours = (s) => { + try { + return serialize(simplify(parse(tokenize(s))), { precision: 10 }); + } catch { + return null; + } +}; +const theirs = (s) => { + try { + const r = csstoolsCalc(s); + return typeof r === 'string' ? r : null; + } catch { + return null; + } +}; +const THREW = ''; +const div = []; +for (const line of lines) { + const o = ours(line); + const t = theirs(line); + if (o === null && t === null) continue; + if (o === null || t === null) { + div.push({ input: line, ours: o ?? THREW, theirs: t ?? THREW }); + continue; + } + if (o === t) continue; + // Re-feed csstools' output through our pipeline to absorb cosmetic noise. + const ct = ours(t); + if (ct !== null && o === ct) continue; + div.push({ input: line, ours: o, theirs: t }); +} +console.log(`Total divergences: ${div.length}`); +console.log('---'); +const buckets = { + we_threw: div.filter((d) => d.ours === THREW), + they_threw: div.filter((d) => d.theirs === THREW), + different_output: div.filter((d) => d.ours !== THREW && d.theirs !== THREW), +}; +console.log(`we_threw: ${buckets.we_threw.length}`); +console.log(`they_threw: ${buckets.they_threw.length}`); +console.log(`different_output: ${buckets.different_output.length}`); +console.log('---'); +console.log('=== we_threw ==='); +for (const d of buckets.we_threw) console.log(d.input); +console.log('=== they_threw ==='); +for (const d of buckets.they_threw.slice(0, 15)) { + console.log(`IN: ${d.input}\nOURS: ${d.ours}`); +} +console.log('=== different_output ==='); +for (const d of buckets.different_output.slice(0, 15)) { + console.log(`IN: ${d.input}\nOURS: ${d.ours}\nTHEIRS: ${d.theirs}\n`); +} diff --git a/scripts/split-corpus.mjs b/scripts/split-corpus.mjs new file mode 100644 index 0000000..eaa6093 --- /dev/null +++ b/scripts/split-corpus.mjs @@ -0,0 +1,52 @@ +// Split harvested expressions.txt into three buckets: +// pure : our parser accepts (drives csstools differential) +// preprocessor : SCSS/Less syntax (parser-only resilience) +// invalid : valid-looking CSS we reject (parser-only resilience) +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tokenize } from '../src/lib/tokenizer.js'; +import { parse } from '../src/lib/parser.js'; +import { simplify } from '../src/lib/simplify.js'; +import { serialize } from '../src/lib/serialize.js'; +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..'); +const SRC = join(ROOT, 'test/corpus/github/expressions.txt'); +const PURE = join(ROOT, 'test/corpus/github-pure.txt'); +const PREP = join(ROOT, 'test/corpus/github/preprocessor.txt'); +const INVALID = join(ROOT, 'test/corpus/github/invalid.txt'); +const PREPROC_RE = /#\{|\$[A-Za-z_]|@[A-Za-z_]|~["']/; +function ourParserAccepts(s) { + try { + serialize(simplify(parse(tokenize(s))), { precision: 10 }); + return true; + } catch { + return false; + } +} +const lines = readFileSync(SRC, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +const pure = []; +const prep = []; +const invalid = []; +for (const line of lines) { + if (PREPROC_RE.test(line)) { + prep.push(line); + } else if (ourParserAccepts(line)) { + pure.push(line); + } else { + invalid.push(line); + } +} +const sorted = (xs) => [...xs].sort((a, b) => a.localeCompare(b)); +writeFileSync(PURE, sorted(pure).join('\n') + '\n'); +writeFileSync(PREP, sorted(prep).join('\n') + '\n'); +writeFileSync(INVALID, sorted(invalid).join('\n') + '\n'); +process.stderr.write( + `pure: ${pure.length}\n` + + `preprocessor: ${prep.length}\n` + + `invalid: ${invalid.length}\n` + + `total: ${lines.length}\n` +); diff --git a/scripts/tokenizer-compat.mjs b/scripts/tokenizer-compat.mjs new file mode 100644 index 0000000..fc8fbd9 --- /dev/null +++ b/scripts/tokenizer-compat.mjs @@ -0,0 +1,134 @@ +// Projects our tokens and csstools-format tokens (library tuples or the +// @rmenke/css-tokenizer-tests JSON) onto one shape and diffs the streams. +// Compares decoded values, normalizing the two designed differences: +// function-token ≡ ident + `(`, and signed numeric ≡ punct sign + numeric. +import { tokenize as ourTokenize } from '../src/lib/tokenizer.js'; +const PUNCT_DELIMS = new Set(['+', '-', '*', '/']); +export class OutOfSubsetError extends Error { + tokenType; + raw; + constructor(tokenType, raw) { + super(`out of subset: ${tokenType} ${JSON.stringify(raw)}`); + this.tokenType = tokenType; + this.raw = raw; + } +} +export function fromCsstools(tokens) { + const out = []; + let ws = true; + const push = (t) => { + out.push({ ...t, ws }); + ws = false; + }; + for (const t of tokens) { + switch (t.type) { + case 'whitespace-token': + case 'comment': + ws = true; + break; + case 'EOF-token': + break; + case 'number-token': + push({ type: 'number', num: t.structured?.value, raw: t.raw }); + break; + case 'dimension-token': + push({ + type: 'dimension', + num: t.structured?.value, + unit: t.structured?.unit, + raw: t.raw, + }); + break; + case 'percentage-token': + push({ + type: 'dimension', + num: t.structured?.value, + unit: '%', + raw: t.raw, + }); + break; + case 'ident-token': + push({ type: 'ident', name: t.structured?.value, raw: t.raw }); + break; + case 'function-token': + push({ type: 'ident', name: t.structured?.value, raw: t.raw }); + push({ type: 'punct', name: '(', raw: '(' }); + break; + case '(-token': + push({ type: 'punct', name: '(', raw: t.raw }); + break; + case ')-token': + push({ type: 'punct', name: ')', raw: t.raw }); + break; + case 'comma-token': + push({ type: 'punct', name: ',', raw: t.raw }); + break; + case 'delim-token': { + const ch = t.structured?.value; + if (!PUNCT_DELIMS.has(ch)) throw new OutOfSubsetError(t.type, t.raw); + push({ type: 'punct', name: ch, raw: t.raw }); + break; + } + default: + throw new OutOfSubsetError(t.type, t.raw); + } + } + return out; +} +export function fromOurs(tokens) { + const out = []; + for (const t of tokens) { + if (t.type === 'eof') continue; + if (t.type === 'number' || t.type === 'dimension') { + out.push({ + type: t.type, + num: parseFloat(t.value), + unit: t.unit, + raw: `${t.value}${t.unit ?? ''}`, + ws: t.ws, + }); + } else { + out.push({ type: t.type, name: t.value, raw: t.value, ws: t.ws }); + } + } + return out; +} +export function tokenizeOursSimple(css) { + return fromOurs(ourTokenize(css)); +} +const isNumeric = (t) => t.type === 'number' || t.type === 'dimension'; +const tokenEq = (a, b) => + a.type === b.type && + a.ws === b.ws && + a.name === b.name && + a.num === b.num && + a.unit === b.unit; +export function compareStreams(ours, theirs) { + let i = 0; + let j = 0; + while (i < ours.length || j < theirs.length) { + const a = ours[i] ?? null; + const b = theirs[j] ?? null; + if (!a || !b) return { index: j, ours: a, theirs: b }; + const next = ours[i + 1]; + if ( + a.type === 'punct' && + (a.name === '+' || a.name === '-') && + next !== undefined && + isNumeric(next) && + !next.ws && + isNumeric(b) && + b.ws === a.ws && + b.unit === next.unit && + b.num === (a.name === '-' ? -next.num : next.num) + ) { + i += 2; + j += 1; + continue; + } + if (!tokenEq(a, b)) return { index: j, ours: a, theirs: b }; + i++; + j++; + } + return null; +} diff --git a/scripts/tokenizer-suite.mjs b/scripts/tokenizer-suite.mjs new file mode 100644 index 0000000..9800110 --- /dev/null +++ b/scripts/tokenizer-suite.mjs @@ -0,0 +1,86 @@ +// Runs the @rmenke/css-tokenizer-tests corpus against our tokenizer. +import { testCorpus } from '@rmenke/css-tokenizer-tests'; +import { tokenize as ourTokenize } from '../src/lib/tokenizer.js'; +import { + fromCsstools, + fromOurs, + compareStreams, + OutOfSubsetError, +} from './tokenizer-compat.mjs'; +const buckets = { + pass: [], + fail: [], + 'out-of-scope': [], +}; +const fmt = (t) => { + if (!t) return ''; + const text = t.name ?? `${t.num}${t.unit ?? ''}`; + const wsFlag = t.ws ? ', ws' : ''; + return `${t.type}(${JSON.stringify(text)}${wsFlag})`; +}; +for (const [name, testCase] of Object.entries(testCorpus)) { + let expected; + try { + expected = fromCsstools(testCase.tokens); + } catch (e) { + if (e instanceof OutOfSubsetError) { + buckets['out-of-scope'].push({ name, why: e.message }); + continue; + } + throw e; + } + let ours; + try { + ours = fromOurs(ourTokenize(testCase.css)); + } catch (e) { + buckets.fail.push({ + name, + css: testCase.css, + detail: `threw: ${e.message}`, + }); + continue; + } + const diff = compareStreams(ours, expected); + if (!diff) { + buckets.pass.push(name); + } else { + buckets.fail.push({ + name, + css: testCase.css, + detail: `token #${diff.index}: ours ${fmt(diff.ours)} vs expected ${fmt(diff.theirs)}`, + }); + } +} +const total = Object.values(buckets).reduce((n, b) => n + b.length, 0); +console.log(`css-tokenizer-tests: ${total} cases`); +console.log(` pass: ${buckets.pass.length}`); +console.log(` fail: ${buckets.fail.length}`); +console.log( + ` out-of-scope: ${buckets['out-of-scope'].length} (token types outside the calc subset)` +); +if (buckets.fail.length) { + console.log('\n=== FAILURES (in-subset divergence — real bugs) ==='); + for (const f of buckets.fail) { + console.log(`CASE: ${f.name}`); + console.log(`CSS: ${JSON.stringify(f.css)}`); + console.log(`DETAIL: ${f.detail}`); + } +} +const byCategory = new Map(); +const bump = (name, key) => { + const cat = name.split('/')[1]; + const e = byCategory.get(cat) ?? { inScope: 0, outOfScope: 0 }; + e[key]++; + byCategory.set(cat, e); +}; +for (const name of buckets.pass) bump(name, 'inScope'); +for (const f of buckets.fail) bump(f.name, 'inScope'); +for (const o of buckets['out-of-scope']) bump(o.name, 'outOfScope'); +console.log('\nPer-category (ran / skipped-out-of-scope):'); +for (const [cat, c] of [...byCategory.entries()].sort((a, b) => + a[0].localeCompare(b[0]) +)) { + console.log( + ` ${cat.padEnd(20)} ${String(c.inScope).padStart(3)} / ${c.outOfScope}` + ); +} diff --git a/scripts/tokenizer-vs-csstools.mjs b/scripts/tokenizer-vs-csstools.mjs new file mode 100644 index 0000000..955f7de --- /dev/null +++ b/scripts/tokenizer-vs-csstools.mjs @@ -0,0 +1,123 @@ +// Diffs both tokenizers' streams over the real-world corpus, then times them. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tokenize as csstoolsTokenize } from '@csstools/css-tokenizer'; +import { tokenize as ourTokenize } from '../src/lib/tokenizer.js'; +import { + fromCsstools, + fromOurs, + compareStreams, + OutOfSubsetError, +} from './tokenizer-compat.mjs'; +const ROOT = dirname(fileURLToPath(import.meta.url)); +const CORPUS = join(ROOT, '..', 'test/corpus/github-pure.txt'); +const corpus = readFileSync(CORPUS, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); +const equal = []; +const weThrew = []; +const outOfSubset = []; +const mismatched = []; +let theirParseErrors = 0; +const fmt = (t) => { + if (!t) return ''; + const text = t.name ?? `${t.num}${t.unit ?? ''}`; + const wsFlag = t.ws ? ', ws' : ''; + return `${t.type}(${JSON.stringify(text)}${wsFlag})`; +}; +for (const input of corpus) { + const errors = []; + const theirTuples = csstoolsTokenize( + { css: input }, + { onParseError: (e) => errors.push(e) } + ).map((t) => ({ + type: t[0], + raw: t[1], + structured: t[4], + })); + theirParseErrors += errors.length; + let theirs; + try { + theirs = fromCsstools(theirTuples); + } catch (e) { + if (e instanceof OutOfSubsetError) { + outOfSubset.push({ input, detail: e.message }); + continue; + } + throw e; + } + let ours; + try { + ours = fromOurs(ourTokenize(input)); + } catch (e) { + weThrew.push({ input, detail: e.message }); + continue; + } + const diff = compareStreams(ours, theirs); + if (diff) { + mismatched.push({ + input, + detail: `token #${diff.index}: ours ${fmt(diff.ours)} vs theirs ${fmt(diff.theirs)}`, + }); + } else { + equal.push(input); + } +} +console.log(`Corpus: ${corpus.length.toLocaleString()} expressions`); +console.log(` identical streams: ${equal.length}`); +console.log(` we threw: ${weThrew.length}`); +console.log( + ` out of our subset: ${outOfSubset.length} (csstools emits token types we don't claim)` +); +console.log(` stream mismatches: ${mismatched.length}`); +console.log(` csstools parse errors: ${theirParseErrors}`); +const show = (label, list, n = 10) => { + if (!list.length) return; + console.log( + `\n=== ${label} (first ${Math.min(n, list.length)} of ${list.length}) ===` + ); + for (const d of list.slice(0, n)) { + console.log(`IN: ${d.input}`); + console.log(`DETAIL: ${d.detail}`); + } +}; +show('we threw', weThrew); +show('out of subset', outOfSubset); +show('mismatches', mismatched); +// --- timing ----------------------------------------------------------- +const time = (fn) => { + const t0 = performance.now(); + fn(); + return performance.now() - t0; +}; +for (let warm = 0; warm < 2; warm++) { + for (const input of corpus) { + try { + ourTokenize(input); + } catch { + /* counted above */ + } + csstoolsTokenize({ css: input }); + } +} +const oursMs = time(() => { + for (const input of corpus) { + try { + ourTokenize(input); + } catch { + /* counted above */ + } + } +}); +const theirsMs = time(() => { + for (const input of corpus) csstoolsTokenize({ css: input }); +}); +console.log( + `\nTokenize-only timing (1 pass, ${corpus.length.toLocaleString()} exprs):` +); +console.log(` ours: ${oursMs.toFixed(1)} ms`); +console.log( + ` csstools: ${theirsMs.toFixed(1)} ms (${(theirsMs / oursMs).toFixed(2)}× ours)` +); diff --git a/src/index.js b/src/index.js index 9e3333b..8009a97 100644 --- a/src/index.js +++ b/src/index.js @@ -1,45 +1,140 @@ 'use strict'; -const transform = require('./lib/transform.js'); + +// PostCSS adapter. Walks declaration values (and optionally @rule params +// and selectors), feeds calc() bodies through tokenize → parse → simplify +// → serialize, and writes the result back. + +const valueParser = require('postcss-value-parser'); + +const { tokenize } = require('./lib/tokenizer.js'); +const { parse } = require('./lib/parser.js'); +const { simplify } = require('./lib/simplify.js'); +const { serialize } = require('./lib/serialize.js'); + +const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; + +// Bare math-function calls (no calc() wrapper) — fed to the same pipeline. +// Mirrors the dispatch in lib/simplify/call.js. +const MATH_FUNCTIONS = new Set([ + 'min', 'max', 'clamp', + 'abs', 'sign', + 'mod', 'rem', 'round', + 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2', + 'pow', 'sqrt', 'hypot', 'log', 'exp', +]); /** - * @typedef {{precision?: number | false, - * preserve?: boolean, - * warnWhenCannotResolve?: boolean, - * mediaQueries?: boolean, - * selectors?: boolean}} PostCssCalcOptions + * @typedef {object} PluginOptions + * @property {number | false} [precision] + * @property {boolean} [preserve] + * @property {boolean} [warnWhenCannotResolve] + * @property {boolean} [mediaQueries] + * @property {boolean} [selectors] + * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws. Replaces the default `result.warn`. */ + +/** @typedef {Required> & Pick} ResolvedOptions */ + +/** + * @param {string} value + * @param {ResolvedOptions} options + * @param {import('postcss').Result} result + * @param {import('postcss').ChildNode} item + * @return {string} + */ +function transformValue(value, options, result, item) { + return valueParser(value) + .walk((node) => { + if (node.type !== 'function') {return;} + const isCalc = MATCH_CALC.test(node.value); + const isMath = !isCalc && MATH_FUNCTIONS.has(node.value.toLowerCase()); + if (!isCalc && !isMath) {return;} + + // calc(): feed the body. Bare math: feed the whole call. + const inner = valueParser.stringify(node.nodes); + const contents = isCalc ? inner : `${node.value}(${inner})`; + try { + const simplified = simplify(parse(tokenize(contents))); + const str = serialize(simplified, { + precision: options.precision, + calcName: isCalc ? node.value : 'calc', // preserve vendor prefix on calc() + }); + + if (options.warnWhenCannotResolve && str.startsWith(`${node.value}(`)) { + result.warn('Could not reduce expression: ' + value, { + plugin: 'postcss-calc', + node: item, + }); + } + + // Re-tag as `word` so value-parser emits `str` verbatim instead of + // re-wrapping it as `name(...)`. Cast widens the `'function'` literal. + /** @type {{type: string}} */ (node).type = 'word'; + node.value = str; + } catch (error) { + const err = error instanceof Error ? error : new Error('Error'); + if (options.onParseError) { + options.onParseError(err, contents); + } else { + result.warn(err.message, { node: item }); + } + } + return false; + }) + .toString(); +} + /** - * @type {import('postcss').PluginCreator} - * @param {PostCssCalcOptions} opts + * @type {import('postcss').PluginCreator} + * @param {PluginOptions} [opts] * @return {import('postcss').Plugin} */ function pluginCreator(opts) { - const options = Object.assign( - { - precision: 5, - preserve: false, - warnWhenCannotResolve: false, - mediaQueries: false, - selectors: false, - }, - opts - ); + /** @type {ResolvedOptions} */ + const options = { + precision: 5, + preserve: false, + warnWhenCannotResolve: false, + mediaQueries: false, + selectors: false, + ...opts, + }; return { postcssPlugin: 'postcss-calc', OnceExit(css, { result }) { css.walk((node) => { - const { type } = node; - if (type === 'decl') { - transform(node, 'value', options, result); + if (node.type === 'decl') { + const next = transformValue(node.value, options, result, node); + if (options.preserve && node.value !== next && node.parent) { + const clone = node.clone(); + clone.value = next; + node.parent.insertBefore(node, clone); + } else { + node.value = next; + } } - - if (type === 'atrule' && options.mediaQueries) { - transform(node, 'params', options, result); + if (node.type === 'atrule' && options.mediaQueries) { + const next = transformValue(node.params, options, result, node); + if (options.preserve && node.params !== next && node.parent) { + const clone = node.clone(); + clone.params = next; + node.parent.insertBefore(node, clone); + } else { + node.params = next; + } } - - if (type === 'rule' && options.selectors) { - transform(node, 'selector', options, result); + if (node.type === 'rule' && options.selectors) { + // Reduces `:nth-child(calc(...))` via the function walk. calc() in a + // quoted attribute value is a literal match, so it's left untouched. + const next = transformValue(node.selector, options, result, node); + if (options.preserve && node.selector !== next && node.parent) { + const clone = node.clone(); + clone.selector = next; + node.parent.insertBefore(node, clone); + } else { + node.selector = next; + } } }); }, diff --git a/src/lib/convertUnit.js b/src/lib/convertUnit.js deleted file mode 100644 index 5f7bc26..0000000 --- a/src/lib/convertUnit.js +++ /dev/null @@ -1,160 +0,0 @@ -'use strict'; -/** - * @type {{[key:string]: {[key:string]: number}}} - */ -const conversions = { - // Absolute length units - px: { - px: 1, - cm: 96 / 2.54, - mm: 96 / 25.4, - q: 96 / 101.6, - in: 96, - pt: 96 / 72, - pc: 16, - }, - cm: { - px: 2.54 / 96, - cm: 1, - mm: 0.1, - q: 0.025, - in: 2.54, - pt: 2.54 / 72, - pc: 2.54 / 6, - }, - mm: { - px: 25.4 / 96, - cm: 10, - mm: 1, - q: 0.25, - in: 25.4, - pt: 25.4 / 72, - pc: 25.4 / 6, - }, - q: { - px: 101.6 / 96, - cm: 40, - mm: 4, - q: 1, - in: 101.6, - pt: 101.6 / 72, - pc: 101.6 / 6, - }, - in: { - px: 1 / 96, - cm: 1 / 2.54, - mm: 1 / 25.4, - q: 1 / 101.6, - in: 1, - pt: 1 / 72, - pc: 1 / 6, - }, - pt: { - px: 0.75, - cm: 72 / 2.54, - mm: 72 / 25.4, - q: 72 / 101.6, - in: 72, - pt: 1, - pc: 12, - }, - pc: { - px: 0.0625, - cm: 6 / 2.54, - mm: 6 / 25.4, - q: 6 / 101.6, - in: 6, - pt: 6 / 72, - pc: 1, - }, - // Angle units - deg: { - deg: 1, - grad: 0.9, - rad: 180 / Math.PI, - turn: 360, - }, - grad: { - deg: 400 / 360, - grad: 1, - rad: 200 / Math.PI, - turn: 400, - }, - rad: { - deg: Math.PI / 180, - grad: Math.PI / 200, - rad: 1, - turn: Math.PI * 2, - }, - turn: { - deg: 1 / 360, - grad: 0.0025, - rad: 0.5 / Math.PI, - turn: 1, - }, - // Duration units - s: { - s: 1, - ms: 0.001, - }, - ms: { - s: 1000, - ms: 1, - }, - // Frequency units - hz: { - hz: 1, - khz: 1000, - }, - khz: { - hz: 0.001, - khz: 1, - }, - // Resolution units - dpi: { - dpi: 1, - dpcm: 1 / 2.54, - dppx: 1 / 96, - }, - dpcm: { - dpi: 2.54, - dpcm: 1, - dppx: 2.54 / 96, - }, - dppx: { - dpi: 96, - dpcm: 96 / 2.54, - dppx: 1, - }, -}; -/** - * @param {number} value - * @param {string} sourceUnit - * @param {string} targetUnit - * @param {number|false} precision - */ -function convertUnit(value, sourceUnit, targetUnit, precision) { - const sourceUnitNormalized = sourceUnit.toLowerCase(); - const targetUnitNormalized = targetUnit.toLowerCase(); - - if (!conversions[targetUnitNormalized]) { - throw new Error('Cannot convert to ' + targetUnit); - } - - if (!conversions[targetUnitNormalized][sourceUnitNormalized]) { - throw new Error('Cannot convert from ' + sourceUnit + ' to ' + targetUnit); - } - - const converted = - conversions[targetUnitNormalized][sourceUnitNormalized] * value; - - if (precision !== false) { - precision = Math.pow(10, Math.ceil(precision) || 5); - - return Math.round(converted * precision) / precision; - } - - return converted; -} - -module.exports = convertUnit; diff --git a/src/lib/node.js b/src/lib/node.js new file mode 100644 index 0000000..cff27fa --- /dev/null +++ b/src/lib/node.js @@ -0,0 +1,187 @@ +'use strict'; + +// Canonical AST. N-ary Sum and Product with signed numeric leaves. +// Invariants enforced by the constructors below: +// +// - Num/Dim values may be any finite number (negatives allowed); a `-5` +// is `Num(-5)`, never `Sum([{sign:-1, Num(5)}])`. One form per value. +// - In a SumTerm with Num/Dim node, sign is always +1; the sign slot is +// reserved for opaque nodes (Ident, Call, Product, multi-term Sum). +// - No Sum directly contains another Sum (flattened on construction). +// - No Product directly contains another Product (flattened). +// - A Sum/Product with one positive element collapses to that element. +// - A Sum/Product with no elements collapses to Num(0) / Num(1). +// - Zero-valued Nums are dropped from sums (they contribute nothing). +// Zero-valued Dims are kept — the unit carries type info. + +/** + * @typedef {{type: 'Num', value: number}} Num + * @typedef {{type: 'Dim', value: number, unit: string}} Dim + * @typedef {{type: 'Ident', name: string}} Ident + * @typedef {{type: 'Call', name: string, args: Node[]}} Call + * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim. + * @typedef {{type: 'Sum', terms: SumTerm[]}} Sum + * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator. + * @typedef {{type: 'Product', factors: ProductFactor[]}} Product + * @typedef {Num | Dim | Ident | Call | Sum | Product} Node + */ + +/** + * @param {number} value + * @return {Num} + */ +function num(value) { + return { type: 'Num', value }; +} + +/** + * @param {number} value + * @param {string} unit + * @return {Dim} + */ +function dim(value, unit) { + return { type: 'Dim', value, unit }; +} + +/** + * @param {string} name + * @return {Ident} + */ +function ident(name) { + return { type: 'Ident', name }; +} + +/** + * @param {string} name + * @param {Node[]} args + * @return {Call} + */ +function call(name, args) { + return { type: 'Call', name, args }; +} + +/** + * @param {SumTerm[]} rawTerms + * @return {Node} + */ +function mkSum(rawTerms) { + /** @type {SumTerm[]} */ + const flat = []; + for (const t of rawTerms) { + pushSumTerm(flat, t); + } + if (flat.length === 0) { + return { type: 'Num', value: 0 }; + } + if (flat.length === 1 && flat[0].sign === 1) { + return flat[0].node; + } + return { type: 'Sum', terms: flat }; +} + +/** + * @param {SumTerm[]} out + * @param {SumTerm} term + * @return {void} + */ +function pushSumTerm(out, term) { + let { sign, node } = term; + + if (node.type === 'Sum') { + for (const inner of node.terms) { + pushSumTerm(out, { + sign: /** @type {1 | -1} */ (sign * inner.sign), + node: inner.node, + }); + } + return; + } + + // sign=-1 around a Num/Dim leaf collapses into the value's sign — the + // canonical-form rule downstream code relies on. + if (sign === -1) { + if (node.type === 'Num') { + node = { type: 'Num', value: -node.value }; + sign = 1; + } else if (node.type === 'Dim') { + node = { type: 'Dim', value: -node.value, unit: node.unit }; + sign = 1; + } + } + + // Drop zero-valued Nums. Dims with value 0 stay — the unit carries type. + if (node.type === 'Num' && node.value === 0) { + return; + } + + out.push({ sign, node }); +} + +/** + * @param {ProductFactor[]} rawFactors + * @return {Node} + */ +function mkProduct(rawFactors) { + /** @type {ProductFactor[]} */ + const flat = []; + for (const f of rawFactors) { + pushProductFactor(flat, f); + } + if (flat.length === 0) { + return { type: 'Num', value: 1 }; + } + if (flat.length === 1 && flat[0].exponent === 1) { + return flat[0].node; + } + return { type: 'Product', factors: flat }; +} + +/** + * @param {ProductFactor[]} out + * @param {ProductFactor} f + * @return {void} + */ +function pushProductFactor(out, f) { + const n = f.node; + if (n.type === 'Product') { + for (const inner of n.factors) { + out.push({ + exponent: /** @type {1 | -1} */ (f.exponent * inner.exponent), + node: inner.node, + }); + } + return; + } + // Factor of 1 contributes nothing regardless of exponent (1/1 = 1). + if (n.type === 'Num' && n.value === 1) { + return; + } + out.push(f); +} + +/** + * Negate any node, preserving canonical form. + * @param {Node} node + * @return {Node} + */ +function negate(node) { + if (node.type === 'Num') { + return num(-node.value); + } + if (node.type === 'Dim') { + return dim(-node.value, node.unit); + } + if (node.type === 'Sum') { + return mkSum( + node.terms.map((t) => ({ + sign: /** @type {1 | -1} */ (-t.sign), + node: t.node, + })) + ); + } + // Opaque (Ident, Call, Product): wrap as a single negative-sign term — + // the only case where sign=-1 remains on a SumTerm. + return { type: 'Sum', terms: [{ sign: -1, node }] }; +} + +module.exports = { num, dim, ident, call, mkSum, mkProduct, negate }; diff --git a/src/lib/parser.js b/src/lib/parser.js new file mode 100644 index 0000000..8e0a708 --- /dev/null +++ b/src/lib/parser.js @@ -0,0 +1,314 @@ +'use strict'; + +// Pratt parser. +/- emit Sum nodes; */÷ emit Product nodes. node.js +// constructors flatten and normalize on construction, so the parser +// never produces a Binary node. + +const { mkSum, mkProduct, negate } = require('./node.js'); + +/** + * @typedef {import('./tokenizer.js').Token} Token + * @typedef {import('./tokenizer.js').TokenType} TokenType + * @typedef {import('./node.js').Node} Node + * @typedef {(p: Parser, token: Token) => Node} PrefixParselet + * @typedef {{lbp: number, parse: (p: Parser, left: Node, token: Token) => Node}} InfixParselet + */ + +/** + * @param {Token} t + * @param {string} value + * @return {boolean} + */ +function isPunct(t, value) { + return t.type === 'punct' && t.value === value; +} + +/** + * §10.9 — case-insensitive except for NaN. + * @param {string} name + * @return {Node | null} + */ +function foldCalcKeyword(name) { + // `NaN` and `-NaN` are spec-defined math constants (§10.7.1). The signed + // form arrives as a single ident because CSS Syntax tokenizes leading + // `-` + ident-start as one ident-token. + if (name === 'NaN' || name === '-NaN') { + return { type: 'Num', value: NaN }; + } + switch (name.toLowerCase()) { + case 'pi': + return { type: 'Num', value: Math.PI }; + case 'e': + return { type: 'Num', value: Math.E }; + case 'infinity': + return { type: 'Num', value: Infinity }; + case '-infinity': + return { type: 'Num', value: -Infinity }; + } + return null; +} + +class Parser { + /** + * @param {Token[]} tokens + */ + constructor(tokens) { + /** @private */ + this.i = 0; + /** @private @readonly */ + this.tokens = tokens; + } + + /** @return {Token} */ + peek() { + return this.tokens[this.i]; + } + + /** @return {Token} */ + next() { + return this.tokens[this.i++]; + } + + /** + * @param {TokenType} type + * @param {string} [value] + * @return {Token} + */ + expect(type, value) { + const t = this.next(); + if (t.type !== type || (value !== undefined && t.value !== value)) { + const want = value ?? type; + throw new Error( + `Expected ${want} at position ${t.pos}, got "${t.value}"` + ); + } + return t; + } + + /** + * @param {number} [minBp] + * @return {Node} + */ + parseExpr(minBp = 0) { + const t = this.next(); + const key = t.type === 'punct' ? t.value : t.type; + const prefix = PREFIX[key]; + if (!prefix) { + throw new Error(`Unexpected token "${t.value}" at position ${t.pos}`); + } + let left = prefix(this, t); + + while (true) { + const nxt = this.peek(); + const infixKey = nxt.type === 'punct' ? nxt.value : nxt.type; + const rule = INFIX[infixKey]; + if (!rule || rule.lbp < minBp) { + break; + } + this.next(); + left = rule.parse(this, left, nxt); + } + + return left; + } +} + +/** + * @param {Node} left + * @param {Node} right + * @param {1 | -1} rightSign + * @return {Node} + */ +function addTerm(left, right, rightSign) { + return mkSum([ + { sign: 1, node: left }, + { sign: rightSign, node: right }, + ]); +} + +/** + * @param {Node} left + * @param {Node} right + * @param {1 | -1} rightExp + * @return {Node} + */ +function mulFactor(left, right, rightExp) { + return mkProduct([ + { exponent: 1, node: left }, + { exponent: rightExp, node: right }, + ]); +} + +const ADD_BP = 1; +const MUL_BP = 3; +const UNARY_BP = 7; + +/** + * Functions whose argument list isn't a comma-separated list of calc + * expressions. Their bodies are slurped as opaque space-separated tokens + * and round-tripped verbatim. anchor() / anchor-size() use the + * ` ` shape (CSS Anchor Positioning). + */ +const OPAQUE_ARG_FUNCTIONS = new Set(['anchor', 'anchor-size']); + +/** + * Reconstruct a token's source text for opaque-arg slurping. + * @param {Token} t + * @return {string} + */ +function tokenText(t) { + if (t.type === 'dimension') {return `${t.value}${t.unit ?? ''}`;} + return t.value; +} + +/** + * Parse the body of an opaque-arg call, with `(` already consumed. + * @param {Parser} p + * @param {string} name + * @return {Node} + */ +function parseOpaqueCall(p, name) { + /** @type {Node[]} */ + const args = []; + let buf = ''; + let depth = 1; + const flush = () => { + const trimmed = buf.trim(); + if (trimmed) {args.push({ type: 'Ident', name: trimmed });} + buf = ''; + }; + while (true) { + const tk = p.peek(); + if (tk.type === 'eof') { + throw new Error(`Unclosed ${name}( at position ${tk.pos}`); + } + if (tk.type === 'punct') { + if (tk.value === '(') {depth++;} + else if (tk.value === ')') { + depth--; + if (depth === 0) { + p.next(); + flush(); + return { type: 'Call', name, args }; + } + } else if (tk.value === ',' && depth === 1) { + p.next(); + flush(); + continue; + } + } + if (tk.ws && buf) {buf += ' ';} + buf += tokenText(tk); + p.next(); + } +} + +/** + * §10.1 requires whitespace on both sides of a binary `+` / `-`. Without + * it, CSS tokenization treats `1px+2px` as two tokens with no operator + * between them (browsers reject this). We enforce the rule here by + * checking the token's `ws` flag (whitespace before the `+`/`-`) and the + * following token's flag (whitespace after). + * @param {Parser} p + * @param {Token} token + * @return {void} + */ +function requireSurroundingWs(p, token) { + const next = p.peek(); + if (!token.ws || !next.ws) { + throw new Error( + `"${token.value}" must be surrounded by whitespace at position ${token.pos}` + ); + } +} + +/** @type {Record} */ +const PREFIX = { + number: (_p, t) => ({ type: 'Num', value: parseFloat(t.value) }), + + // Unit case normalization per §10.12: `1PX` serializes as `1px`. + dimension: (_p, t) => ({ + type: 'Dim', + value: parseFloat(t.value), + unit: t.unit === '%' ? '%' : /** @type {string} */ (t.unit).toLowerCase(), + }), + + ident: (p, t) => { + const nxt = p.peek(); + if (nxt.type === 'punct' && nxt.value === '(') { + p.next(); + if (OPAQUE_ARG_FUNCTIONS.has(t.value.toLowerCase())) { + return parseOpaqueCall(p, t.value); + } + /** @type {Node[]} */ + const args = []; + if (!isPunct(p.peek(), ')')) { + args.push(p.parseExpr(0)); + while (isPunct(p.peek(), ',')) { + p.next(); + args.push(p.parseExpr(0)); + } + } + p.expect('punct', ')'); + return { type: 'Call', name: t.value, args }; + } + const kw = foldCalcKeyword(t.value); + if (kw) { + return kw; + } + return { type: 'Ident', name: t.value }; + }, + + '(': (p) => { + const e = p.parseExpr(0); + p.expect('punct', ')'); + return e; + }, + + '-': (p) => negate(p.parseExpr(UNARY_BP)), + '+': (p) => p.parseExpr(UNARY_BP), +}; + +/** @type {Record} */ +const INFIX = { + '+': { + lbp: ADD_BP, + parse: (p, left, token) => { + requireSurroundingWs(p, token); + return addTerm(left, p.parseExpr(ADD_BP + 1), 1); + }, + }, + '-': { + lbp: ADD_BP, + parse: (p, left, token) => { + requireSurroundingWs(p, token); + return addTerm(left, p.parseExpr(ADD_BP + 1), -1); + }, + }, + '*': { + lbp: MUL_BP, + parse: (p, left) => mulFactor(left, p.parseExpr(MUL_BP + 1), 1), + }, + '/': { + lbp: MUL_BP, + parse: (p, left) => mulFactor(left, p.parseExpr(MUL_BP + 1), -1), + }, +}; + +/** + * @param {Token[]} tokens + * @return {Node} + */ +function parse(tokens) { + const p = new Parser(tokens); + const ast = p.parseExpr(0); + const trailing = p.peek(); + if (trailing.type !== 'eof') { + throw new Error( + `Unexpected token "${trailing.value}" at position ${trailing.pos}` + ); + } + return ast; +} + +module.exports = { parse }; diff --git a/src/lib/reducer.js b/src/lib/reducer.js deleted file mode 100644 index 3ad40d8..0000000 --- a/src/lib/reducer.js +++ /dev/null @@ -1,396 +0,0 @@ -'use strict'; -const convertUnit = require('./convertUnit.js'); - -/** - * @param {import('../parser').CalcNode} node - * @return {node is import('../parser').ValueExpression} - */ -function isValueType(node) { - switch (node.type) { - case 'LengthValue': - case 'AngleValue': - case 'TimeValue': - case 'FrequencyValue': - case 'ResolutionValue': - case 'EmValue': - case 'ExValue': - case 'ChValue': - case 'RemValue': - case 'VhValue': - case 'SvhValue': - case 'LvhValue': - case 'DvhValue': - case 'VwValue': - case 'SvwValue': - case 'LvwValue': - case 'DvwValue': - case 'VminValue': - case 'SvminValue': - case 'LvminValue': - case 'DvminValue': - case 'VmaxValue': - case 'SvmaxValue': - case 'LvmaxValue': - case 'DvmaxValue': - case 'VbValue': - case 'SvbValue': - case 'LvbValue': - case 'DvbValue': - case 'ViValue': - case 'SviValue': - case 'LviValue': - case 'DviValue': - case 'CqwValue': - case 'CqhValue': - case 'CqiValue': - case 'CqbValue': - case 'CqminValue': - case 'CqmaxValue': - case 'PercentageValue': - case 'LhValue': - case 'RlhValue': - case 'Number': - return true; - } - return false; -} - -/** @param {'-'|'+'} operator */ -function flip(operator) { - return operator === '+' ? '-' : '+'; -} - -/** - * @param {string} operator - * @returns {operator is '+'|'-'} - */ -function isAddSubOperator(operator) { - return operator === '+' || operator === '-'; -} - -/** - * @typedef {{preOperator: '+'|'-', node: import('../parser').CalcNode}} Collectible - */ - -/** - * @param {'+'|'-'} preOperator - * @param {import('../parser').CalcNode} node - * @param {Collectible[]} collected - * @param {number} precision - */ -function collectAddSubItems(preOperator, node, collected, precision) { - if (!isAddSubOperator(preOperator)) { - throw new Error(`invalid operator ${preOperator}`); - } - if (isValueType(node)) { - const itemIndex = collected.findIndex((x) => x.node.type === node.type); - if (itemIndex >= 0) { - if (node.value === 0) { - return; - } - // can cast because of the criterion used to find itemIndex - const otherValueNode = /** @type import('../parser').ValueExpression*/ ( - collected[itemIndex].node - ); - const { left: reducedNode, right: current } = convertNodesUnits( - otherValueNode, - node, - precision - ); - - if (collected[itemIndex].preOperator === '-') { - collected[itemIndex].preOperator = '+'; - reducedNode.value *= -1; - } - if (preOperator === '+') { - reducedNode.value += current.value; - } else { - reducedNode.value -= current.value; - } - // make sure reducedNode.value >= 0 - if (reducedNode.value >= 0) { - collected[itemIndex] = { node: reducedNode, preOperator: '+' }; - } else { - reducedNode.value *= -1; - collected[itemIndex] = { node: reducedNode, preOperator: '-' }; - } - } else { - // make sure node.value >= 0 - if (node.value >= 0) { - collected.push({ node, preOperator }); - } else { - node.value *= -1; - collected.push({ node, preOperator: flip(preOperator) }); - } - } - } else if (node.type === 'MathExpression') { - if (isAddSubOperator(node.operator)) { - collectAddSubItems(preOperator, node.left, collected, precision); - const collectRightOperator = - preOperator === '-' ? flip(node.operator) : node.operator; - collectAddSubItems( - collectRightOperator, - node.right, - collected, - precision - ); - } else { - // * or / - const reducedNode = reduce(node, precision); - // prevent infinite recursive call - if ( - reducedNode.type !== 'MathExpression' || - isAddSubOperator(reducedNode.operator) - ) { - collectAddSubItems(preOperator, reducedNode, collected, precision); - } else { - collected.push({ node: reducedNode, preOperator }); - } - } - } else if (node.type === 'ParenthesizedExpression') { - collectAddSubItems(preOperator, node.content, collected, precision); - } else { - collected.push({ node, preOperator }); - } -} - -/** - * @param {import('../parser').CalcNode} node - * @param {number} precision - */ -function reduceAddSubExpression(node, precision) { - /** @type Collectible[] */ - const collected = []; - collectAddSubItems('+', node, collected, precision); - - const withoutZeroItem = collected.filter( - (item) => !(isValueType(item.node) && item.node.value === 0) - ); - const firstNonZeroItem = withoutZeroItem[0]; // could be undefined - - // prevent producing "calc(-var(--a))" or "calc()" - // which is invalid css - if ( - !firstNonZeroItem || - (firstNonZeroItem.preOperator === '-' && - !isValueType(firstNonZeroItem.node)) - ) { - const firstZeroItem = collected.find( - (item) => isValueType(item.node) && item.node.value === 0 - ); - if (firstZeroItem) { - withoutZeroItem.unshift(firstZeroItem); - } - } - - // make sure the preOperator of the first item is + - if ( - withoutZeroItem[0].preOperator === '-' && - isValueType(withoutZeroItem[0].node) - ) { - withoutZeroItem[0].node.value *= -1; - withoutZeroItem[0].preOperator = '+'; - } - - let root = withoutZeroItem[0].node; - for (let i = 1; i < withoutZeroItem.length; i++) { - root = { - type: 'MathExpression', - operator: withoutZeroItem[i].preOperator, - left: root, - right: withoutZeroItem[i].node, - }; - } - - return root; -} -/** - * @param {import('../parser').MathExpression} node - */ -function reduceDivisionExpression(node) { - if (!isValueType(node.right)) { - return node; - } - - if (node.right.type !== 'Number') { - throw new Error(`Cannot divide by "${node.right.unit}", number expected`); - } - - return applyNumberDivision(node.left, node.right.value); -} - -/** - * apply (expr) / number - * - * @param {import('../parser').CalcNode} node - * @param {number} divisor - * @return {import('../parser').CalcNode} - */ -function applyNumberDivision(node, divisor) { - if (divisor === 0) { - throw new Error('Cannot divide by zero'); - } - if (isValueType(node)) { - node.value /= divisor; - return node; - } - if (node.type === 'MathExpression' && isAddSubOperator(node.operator)) { - // turn (a + b) / num into a/num + b/num - // is good for further reduction - // checkout the test case - // "should reduce division before reducing additions" - return { - type: 'MathExpression', - operator: node.operator, - left: applyNumberDivision(node.left, divisor), - right: applyNumberDivision(node.right, divisor), - }; - } - // it is impossible to reduce it into a single value - // .e.g the node contains css variable - // so we just preserve the division and let browser do it - return { - type: 'MathExpression', - operator: '/', - left: node, - right: { - type: 'Number', - value: divisor, - }, - }; -} -/** - * @param {import('../parser').MathExpression} node - */ -function reduceMultiplicationExpression(node) { - // (expr) * number - if (node.right.type === 'Number') { - return applyNumberMultiplication(node.left, node.right.value); - } - // number * (expr) - if (node.left.type === 'Number') { - return applyNumberMultiplication(node.right, node.left.value); - } - return node; -} - -/** - * apply (expr) * number - * @param {number} multiplier - * @param {import('../parser').CalcNode} node - * @return {import('../parser').CalcNode} - */ -function applyNumberMultiplication(node, multiplier) { - if (isValueType(node)) { - node.value *= multiplier; - return node; - } - if (node.type === 'MathExpression' && isAddSubOperator(node.operator)) { - // turn (a + b) * num into a*num + b*num - // is good for further reduction - // checkout the test case - // "should reduce multiplication before reducing additions" - return { - type: 'MathExpression', - operator: node.operator, - left: applyNumberMultiplication(node.left, multiplier), - right: applyNumberMultiplication(node.right, multiplier), - }; - } - // it is impossible to reduce it into a single value - // .e.g the node contains css variable - // so we just preserve the division and let browser do it - return { - type: 'MathExpression', - operator: '*', - left: node, - right: { - type: 'Number', - value: multiplier, - }, - }; -} - -/** - * @param {import('../parser').ValueExpression} left - * @param {import('../parser').ValueExpression} right - * @param {number} precision - */ -function convertNodesUnits(left, right, precision) { - switch (left.type) { - case 'LengthValue': - case 'AngleValue': - case 'TimeValue': - case 'FrequencyValue': - case 'ResolutionValue': - if (right.type === left.type && right.unit && left.unit) { - const converted = convertUnit( - right.value, - right.unit, - left.unit, - precision - ); - - right = { - type: left.type, - value: converted, - unit: left.unit, - }; - } - - return { left, right }; - default: - return { left, right }; - } -} - -/** - * @param {import('../parser').ParenthesizedExpression} node - */ -function includesNoCssProperties(node) { - return ( - node.content.type !== 'Function' && - (node.content.type !== 'MathExpression' || - (node.content.right.type !== 'Function' && - node.content.left.type !== 'Function')) - ); -} -/** - * @param {import('../parser').CalcNode} node - * @param {number} precision - * @return {import('../parser').CalcNode} - */ -function reduce(node, precision) { - if ( - node.type === 'MathExpression' && - (node.left.type === 'CalcKeyword' || node.right.type === 'CalcKeyword') - ) { - return node; - } - if (node.type === 'MathExpression') { - if (isAddSubOperator(node.operator)) { - // reduceAddSubExpression will call reduce recursively - return reduceAddSubExpression(node, precision); - } - node.left = reduce(node.left, precision); - node.right = reduce(node.right, precision); - switch (node.operator) { - case '/': - return reduceDivisionExpression(node); - case '*': - return reduceMultiplicationExpression(node); - } - - return node; - } - - if (node.type === 'ParenthesizedExpression') { - if (includesNoCssProperties(node)) { - return reduce(node.content, precision); - } - } - - return node; -} - -module.exports = reduce; diff --git a/src/lib/serialize.js b/src/lib/serialize.js new file mode 100644 index 0000000..086abcf --- /dev/null +++ b/src/lib/serialize.js @@ -0,0 +1,217 @@ +'use strict'; + +// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree +// Outer calc() is added only when the top-level result contains an +// arithmetic operator. A Sum inside a Product is the only place parens +// are ever required on valid canonical input. + +/** + * @typedef {import('./node.js').Node} Node + * @typedef {import('./node.js').Sum} Sum + * @typedef {import('./node.js').Product} Product + * @typedef {import('./node.js').ProductFactor} ProductFactor + * @typedef {object} SerializeOptions + * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5. + * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. + */ + +/** + * @param {number} v + * @param {number | false} prec + * @return {number} + */ +function round(v, prec) { + if (prec === false) { + return v; + } + const m = Math.pow(10, prec); + return Math.round(v * m) / m; +} + +// §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. +/** + * @param {number} v + * @return {boolean} + */ +function isDegenerate(v) { + return !isFinite(v) || isNaN(v); +} + +/** + * @param {number} v + * @return {string} + */ +function degenerateKeyword(v) { + if (isNaN(v)) {return 'NaN';} + return v > 0 ? 'infinity' : '-infinity'; +} + +/** + * @param {Node} node + * @param {SerializeOptions} [opts] + * @return {string} + */ +function serialize(node, opts = {}) { + const prec = opts.precision ?? 5; + const calcName = opts.calcName ?? 'calc'; + + // §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry + // the unit as ` * 1` so the result keeps its type. + if (node.type === 'Num' && isDegenerate(node.value)) { + return `${calcName}(${degenerateKeyword(node.value)})`; + } + if (node.type === 'Dim' && isDegenerate(node.value)) { + return `${calcName}(${degenerateKeyword(node.value)} * 1${node.unit})`; + } + + if ( + node.type === 'Num' || + node.type === 'Dim' || + node.type === 'Ident' || + node.type === 'Call' + ) { + return serializeExpr(node, prec); + } + + // Single-term Sum is the canonical form for `-var(--x)` / `-(a*b)` — + // sign=-1 around an opaque node. Signed leaves live in Num/Dim directly. + if (node.type === 'Sum' && node.terms.length === 1) { + return `${calcName}(${serializeLeadingNeg(node.terms[0].node, prec)})`; + } + + return `${calcName}(${serializeExpr(node, prec)})`; +} + +// --- Inside calc() expression -------------------------------------------- + +/** + * @param {Node} node + * @param {number | false} prec + * @return {string} + */ +function serializeExpr(node, prec) { + switch (node.type) { + case 'Num': + if (isDegenerate(node.value)) {return degenerateKeyword(node.value);} + return String(round(node.value, prec)); + case 'Dim': + if (isDegenerate(node.value)) { + // Nested degenerate Dim wraps in calc() so the ` * 1` form + // parses back as one Dim factor. The bare form round-trips wrong + // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN. + return `calc(${degenerateKeyword(node.value)} * 1${node.unit})`; + } + return `${round(node.value, prec)}${node.unit}`; + case 'Ident': + return node.name; + case 'Call': { + const args = node.args.map((a) => serializeExpr(a, prec)).join(', '); + return `${node.name}(${args})`; + } + case 'Sum': + return serializeSum(node, prec); + case 'Product': + return serializeProduct(node, prec); + } +} + +/** + * Combine the term's sign with a negative Num/Dim value's sign so + * `{sign:+1, Num(-5)}` renders as `-5`, not `+ -5`. Skip degenerate + * (Infinity/NaN) values — the `degenerateKeyword` path emits `-infinity` + * inline, and a leading minus on `calc(infinity*1)` would now + * tokenize as a `-calc` function. + * @param {{sign: 1 | -1, node: Node}} term + * @return {{sign: 1 | -1, magnitude: Node}} + */ +function displaySign(term) { + const { sign, node } = term; + if (node.type === 'Num' && isFinite(node.value) && node.value < 0) { + return { + sign: /** @type {1 | -1} */ (-sign), + magnitude: { type: 'Num', value: -node.value }, + }; + } + if (node.type === 'Dim' && isFinite(node.value) && node.value < 0) { + return { + sign: /** @type {1 | -1} */ (-sign), + magnitude: { type: 'Dim', value: -node.value, unit: node.unit }, + }; + } + return { sign, magnitude: node }; +} + +/** + * @param {Sum} sum + * @param {number | false} prec + * @return {string} + */ +function serializeSum(sum, prec) { + let out = ''; + sum.terms.forEach((t, i) => { + const { sign, magnitude } = displaySign(t); + if (i === 0) { + out = sign === 1 + ? serializeExpr(magnitude, prec) + : serializeLeadingNeg(magnitude, prec); + } else { + // `-` binds looser than `*`/`/` so the right side never needs parens. + const body = serializeExpr(magnitude, prec); + out += sign === 1 ? ` + ${body}` : ` - ${body}`; + } + }); + return out; +} + +/** + * Fold a leading negation into a finite leading Num if there is one + * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`. + * @param {Node} node + * @param {number | false} prec + * @return {string} + */ +function serializeLeadingNeg(node, prec) { + if ( + node.type === 'Product' && + node.factors.length > 0 && + node.factors[0].exponent === 1 && + node.factors[0].node.type === 'Num' && + isFinite(node.factors[0].node.value) + ) { + const head = node.factors[0].node; + /** @type {ProductFactor[]} */ + const negatedFactors = [ + { exponent: 1, node: { type: 'Num', value: -head.value } }, + ...node.factors.slice(1), + ]; + return serializeProduct({ type: 'Product', factors: negatedFactors }, prec); + } + const body = serializeExpr(node, prec); + return node.type === 'Sum' || node.type === 'Product' ? `-(${body})` : `-${body}`; +} + +/** + * @param {Product} product + * @param {number | false} prec + * @return {string} + */ +function serializeProduct(product, prec) { + let out = ''; + product.factors.forEach((f, i) => { + let body = serializeExpr(f.node, prec); + // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means + // this is the only place parens are required. + if (f.node.type === 'Sum') { + body = `(${body})`; + } + if (i === 0) { + // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`. + out = f.exponent === 1 ? body : `1 / ${body}`; + } else { + out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`; + } + }); + return out; +} + +module.exports = { serialize }; diff --git a/src/lib/simplify.js b/src/lib/simplify.js new file mode 100644 index 0000000..9558c6c --- /dev/null +++ b/src/lib/simplify.js @@ -0,0 +1,38 @@ +'use strict'; + +// Spec: https://www.w3.org/TR/css-values-4/#calc-simplification +// One top-down pass over a canonical AST. Per-concern fold modules in +// ./simplify/; this file is the entry + dispatch only. + +const { simplifySum } = require('./simplify/sum.js'); +const { simplifyProduct } = require('./simplify/product.js'); +const { simplifyCall } = require('./simplify/call.js'); + +/** + * @typedef {import('./node.js').Node} Node + * + * Recursive simplifier reference, threaded into Sum/Product/Call. Lets + * leaf fold modules avoid circular imports of the entry function. + * @typedef {(node: Node) => Node} SimplifyFn + */ + +/** + * @param {Node} node + * @return {Node} + */ +function simplify(node) { + switch (node.type) { + case 'Num': + case 'Dim': + case 'Ident': + return node; + case 'Call': + return simplifyCall(node, simplify); + case 'Sum': + return simplifySum(node, simplify); + case 'Product': + return simplifyProduct(node, simplify); + } +} + +module.exports = { simplify }; diff --git a/src/lib/simplify/abs.js b/src/lib/simplify/abs.js new file mode 100644 index 0000000..21c8754 --- /dev/null +++ b/src/lib/simplify/abs.js @@ -0,0 +1,21 @@ +'use strict'; + +const { num, dim } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyAbs(args) { + if (args.length !== 1) { + return { type: 'Call', name: 'abs', args }; + } + const a = args[0]; + if (a.type === 'Num') {return num(Math.abs(a.value));} + if (a.type === 'Dim' && a.unit !== '%') {return dim(Math.abs(a.value), a.unit);} + return { type: 'Call', name: 'abs', args: [a] }; +} + +module.exports = { simplifyAbs }; diff --git a/src/lib/simplify/atan2.js b/src/lib/simplify/atan2.js new file mode 100644 index 0000000..18b1148 --- /dev/null +++ b/src/lib/simplify/atan2.js @@ -0,0 +1,25 @@ +'use strict'; + +// §10.4 — atan2. foldConstArgs already rejects percentages (property- +// context-resolved) and enforces shared base + static convertibility. + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyAtan2(args) { + if (args.length !== 2) {return { type: 'Call', name: 'atan2', args };} + const fold = foldConstArgs(args); + if (fold === null) {return { type: 'Call', name: 'atan2', args };} + const [y, x] = /** @type {[number, number]} */ (fold.values); + const radians = Math.atan2(y, x); + if (isNaN(radians)) {return num(NaN);} + return dim((radians * 180) / Math.PI, 'deg'); +} + +module.exports = { simplifyAtan2 }; diff --git a/src/lib/simplify/bucket.js b/src/lib/simplify/bucket.js new file mode 100644 index 0000000..5204fa4 --- /dev/null +++ b/src/lib/simplify/bucket.js @@ -0,0 +1,48 @@ +'use strict'; + +// §10.10 phase 2: merge convertible same-base unit buckets into the +// first-encountered unit (px absorbs cm/in/pt/pc, deg absorbs +// rad/grad/turn, …). Buckets with `base === null` (relative or unknown +// units) keep their own slot. + +const { convert } = require('../type.js'); + +/** + * @typedef {object} UnitBucket + * @property {string} unit + * @property {number} total + * @property {import('../type.js').BaseType | null} base + * @property {number} order + */ + +/** Mutates `buckets` in place — totals of survivor buckets accumulate the + * converted values of merged neighbors. Caller must not reuse the input. + * @param {UnitBucket[]} buckets + * @return {UnitBucket[]} + */ +function mergeConvertibleBuckets(buckets) { + const ordered = [...buckets].sort((a, b) => a.order - b.order); + /** @type {Set} */ const merged = new Set(); + /** @type {UnitBucket[]} */ const out = []; + for (const b of ordered) { + const keyB = b.unit.toLowerCase(); + if (merged.has(keyB)) {continue;} + merged.add(keyB); + if (b.base !== null) { + for (const other of ordered) { + const keyO = other.unit.toLowerCase(); + if (merged.has(keyO)) {continue;} + if (other.base !== b.base) {continue;} + const converted = convert(other.total, other.unit, b.unit); + if (converted !== null) { + b.total += converted; + merged.add(keyO); + } + } + } + out.push(b); + } + return out; +} + +module.exports = { mergeConvertibleBuckets }; diff --git a/src/lib/simplify/call.js b/src/lib/simplify/call.js new file mode 100644 index 0000000..a3556c4 --- /dev/null +++ b/src/lib/simplify/call.js @@ -0,0 +1,59 @@ +'use strict'; + +// Pre-simplify args once, route by name. Leaf folds receive simplified +// args so they don't need to recurse into `simplify` themselves. + +const { simplifyMinMax } = require('./min-max.js'); +const { simplifyClamp } = require('./clamp.js'); +const { simplifyAbs } = require('./abs.js'); +const { simplifySign } = require('./sign.js'); +const { simplifyModRem } = require('./mod-rem.js'); +const { simplifyRound } = require('./round.js'); +const { simplifyTrig } = require('./trig.js'); +const { simplifyInverseTrig } = require('./inverse-trig.js'); +const { simplifyAtan2 } = require('./atan2.js'); +const { simplifyPow } = require('./pow.js'); +const { simplifySqrt } = require('./sqrt.js'); +const { simplifyExp } = require('./exp.js'); +const { simplifyLog } = require('./log.js'); +const { simplifyHypot } = require('./hypot.js'); + +/** @typedef {import('../node.js').Node} Node */ +/** @typedef {import('../simplify.js').SimplifyFn} SimplifyFn */ + +/** + * @param {Extract} node + * @param {SimplifyFn} simplify + * @return {Node} + */ +function simplifyCall(node, simplify) { + const name = node.name.toLowerCase(); + + if (name === 'calc' || name === '-webkit-calc' || name === '-moz-calc') { + if (node.args.length !== 1) { + throw new Error(`${node.name}() takes exactly one argument`); + } + return simplify(node.args[0]); + } + + const args = node.args.map((a) => simplify(a)); + + if (name === 'min' || name === 'max') {return simplifyMinMax(node.name, args);} + if (name === 'clamp') {return simplifyClamp(args);} + if (name === 'abs') {return simplifyAbs(args);} + if (name === 'sign') {return simplifySign(args);} + if (name === 'mod' || name === 'rem') {return simplifyModRem(name, args);} + if (name === 'round') {return simplifyRound(args);} + if (name === 'sin' || name === 'cos' || name === 'tan') {return simplifyTrig(name, args);} + if (name === 'asin' || name === 'acos' || name === 'atan') {return simplifyInverseTrig(name, args);} + if (name === 'atan2') {return simplifyAtan2(args);} + if (name === 'pow') {return simplifyPow(args);} + if (name === 'sqrt') {return simplifySqrt(args);} + if (name === 'hypot') {return simplifyHypot(args);} + if (name === 'log') {return simplifyLog(args);} + if (name === 'exp') {return simplifyExp(args);} + + return { type: 'Call', name: node.name, args }; +} + +module.exports = { simplifyCall }; diff --git a/src/lib/simplify/cancel.js b/src/lib/simplify/cancel.js new file mode 100644 index 0000000..f9bcd22 --- /dev/null +++ b/src/lib/simplify/cancel.js @@ -0,0 +1,39 @@ +'use strict'; + +const { baseOf, convert } = require('../type.js'); + +/** + * If `dims` contain exactly one numerator / one denominator pair with the + * same base type and convertible units, return the numeric factor produced + * by cancelling them and the list of remaining (uncancelled) dims. + * Otherwise return null. Used by `simplifyProduct` for typed division + * (§10.2). More complex cancellation (e.g. `px^2 / px`) is left + * unreduced — consumers rarely rely on it and the spec doesn't require it. + * @template {{ exponent: 1 | -1, value: number, unit: string }} D + * @param {D[]} dims + * @return {{ factor: number, remaining: D[] } | null} + */ +function tryCancelPair(dims) { + if (dims.length !== 2) { + return null; + } + const [a, b] = /** @type {[D, D]} */ (dims); + if (a.exponent === b.exponent) { + return null; + } + const numerator = a.exponent === 1 ? a : b; + const denominator = a.exponent === 1 ? b : a; + const numBase = baseOf(numerator.unit); + const denBase = baseOf(denominator.unit); + if (!numBase || numBase !== denBase) { + return null; + } + const converted = convert(numerator.value, numerator.unit, denominator.unit); + if (converted === null) { + return null; + } + // denominator.value === 0 yields ±Infinity / NaN naturally (§10.9.1). + return { factor: converted / denominator.value, remaining: [] }; +} + +module.exports = { tryCancelPair }; diff --git a/src/lib/simplify/clamp.js b/src/lib/simplify/clamp.js new file mode 100644 index 0000000..66992b5 --- /dev/null +++ b/src/lib/simplify/clamp.js @@ -0,0 +1,26 @@ +'use strict'; + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyClamp(args) { + if (args.length === 3) { + const fold = foldConstArgs(args); + if (fold !== null) { + const [lo, v, hi] = /** @type {[number, number, number]} */ (fold.values); + // Spec §10.8: clamp(MIN, VAL, MAX) = max(MIN, min(VAL, MAX)). The + // outer max(MIN, …) means MIN wins when MIN > MAX — not MAX. + const clamped = Math.max(lo, Math.min(v, hi)); + return fold.unit === '' ? num(clamped) : dim(clamped, fold.unit); + } + } + return { type: 'Call', name: 'clamp', args }; +} + +module.exports = { simplifyClamp }; diff --git a/src/lib/simplify/exp.js b/src/lib/simplify/exp.js new file mode 100644 index 0000000..1261714 --- /dev/null +++ b/src/lib/simplify/exp.js @@ -0,0 +1,18 @@ +'use strict'; + +const { num } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyExp(args) { + if (args.length !== 1 || args[0].type !== 'Num') { + return { type: 'Call', name: 'exp', args }; + } + return num(Math.exp(args[0].value)); +} + +module.exports = { simplifyExp }; diff --git a/src/lib/simplify/fold.js b/src/lib/simplify/fold.js new file mode 100644 index 0000000..8b99dfa --- /dev/null +++ b/src/lib/simplify/fold.js @@ -0,0 +1,63 @@ +'use strict'; + +// Fold args to a common numeric representation if they share a type. +// Percentages never fold — ordering depends on property-context +// resolution we don't have. Matches csstools. + +const { baseOf, convert } = require('../type.js'); + +/** @typedef {import('../node.js').Node} Node */ +/** @typedef {import('../type.js').BaseType} BaseType */ + +/** + * @param {Node[]} args + * @return {{ values: number[], unit: string } | null} + */ +function foldConstArgs(args) { + if (args.length === 0) {return null;} + + const first = args[0]; + if (first.type === 'Num') { + return foldNumberArgs(args); + } + if (first.type === 'Dim') { + const b = first.unit === '%' ? null : baseOf(first.unit); + if (!b) {return null;} + return foldDimArgs(args, first.unit, b); + } + return null; +} + +/** + * @param {Node[]} args + * @return {{ values: number[], unit: '' } | null} + */ +function foldNumberArgs(args) { + /** @type {number[]} */ const values = []; + for (const a of args) { + if (a.type !== 'Num') {return null;} + values.push(a.value); + } + return { values, unit: '' }; +} + +/** + * @param {Node[]} args + * @param {string} unit + * @param {BaseType} base + * @return {{ values: number[], unit: string } | null} + */ +function foldDimArgs(args, unit, base) { + /** @type {number[]} */ const values = []; + for (const a of args) { + if (a.type !== 'Dim' || a.unit === '%' || baseOf(a.unit) !== base) { + return null; + } + const converted = convert(a.value, a.unit, unit); + if (converted === null) {return null;} + values.push(converted); + } + return { values, unit }; +} + +module.exports = { foldConstArgs }; diff --git a/src/lib/simplify/hypot.js b/src/lib/simplify/hypot.js new file mode 100644 index 0000000..bbedcb1 --- /dev/null +++ b/src/lib/simplify/hypot.js @@ -0,0 +1,22 @@ +'use strict'; + +// §10.5 — hypot. Empty args return null from foldConstArgs naturally. + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyHypot(args) { + const fold = foldConstArgs(args); + if (fold === null) {return { type: 'Call', name: 'hypot', args };} + const sumSq = fold.values.reduce((acc, v) => acc + v * v, 0); + const result = Math.sqrt(sumSq); + return fold.unit === '' ? num(result) : dim(result, fold.unit); +} + +module.exports = { simplifyHypot }; diff --git a/src/lib/simplify/inverse-trig.js b/src/lib/simplify/inverse-trig.js new file mode 100644 index 0000000..81a15b5 --- /dev/null +++ b/src/lib/simplify/inverse-trig.js @@ -0,0 +1,29 @@ +'use strict'; + +// §10.4 — asin/acos/atan. Bare in, in degrees out. + +const { num, dim } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +const INVERSE_TRIG_OPS = /** @type {const} */ ({ + asin: Math.asin, + acos: Math.acos, + atan: Math.atan, +}); + +/** + * @param {'asin' | 'acos' | 'atan'} name + * @param {Node[]} args + * @return {Node} + */ +function simplifyInverseTrig(name, args) { + if (args.length !== 1) {return { type: 'Call', name, args };} + const a = args[0]; + if (a.type !== 'Num') {return { type: 'Call', name, args };} + const radians = INVERSE_TRIG_OPS[name](a.value); + if (isNaN(radians)) {return num(NaN);} + return dim((radians * 180) / Math.PI, 'deg'); +} + +module.exports = { simplifyInverseTrig }; diff --git a/src/lib/simplify/log.js b/src/lib/simplify/log.js new file mode 100644 index 0000000..a6f2d47 --- /dev/null +++ b/src/lib/simplify/log.js @@ -0,0 +1,25 @@ +'use strict'; + +const { num } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyLog(args) { + if (args.length === 1 && args[0].type === 'Num') { + return num(Math.log(args[0].value)); + } + if ( + args.length === 2 && + args[0].type === 'Num' && + args[1].type === 'Num' + ) { + return num(Math.log(args[0].value) / Math.log(args[1].value)); + } + return { type: 'Call', name: 'log', args }; +} + +module.exports = { simplifyLog }; diff --git a/src/lib/simplify/min-max.js b/src/lib/simplify/min-max.js new file mode 100644 index 0000000..ced1f25 --- /dev/null +++ b/src/lib/simplify/min-max.js @@ -0,0 +1,23 @@ +'use strict'; + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {string} name + * @param {Node[]} args + * @return {Node} + */ +function simplifyMinMax(name, args) { + const fold = foldConstArgs(args); + if (fold !== null) { + const fn = name.toLowerCase() === 'min' ? Math.min : Math.max; + const value = fn(...fold.values); + return fold.unit === '' ? num(value) : dim(value, fold.unit); + } + return { type: 'Call', name, args }; +} + +module.exports = { simplifyMinMax }; diff --git a/src/lib/simplify/mod-rem.js b/src/lib/simplify/mod-rem.js new file mode 100644 index 0000000..b275bb1 --- /dev/null +++ b/src/lib/simplify/mod-rem.js @@ -0,0 +1,45 @@ +'use strict'; + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {'mod' | 'rem'} name + * @param {Node[]} args + * @return {Node} + */ +function simplifyModRem(name, args) { + if (args.length !== 2) {return { type: 'Call', name, args };} + const fold = foldConstArgs(args); + if (fold === null) {return { type: 'Call', name, args };} + const [a, b] = /** @type {[number, number]} */ (fold.values); + const result = applyModRem(name, a, b); + // NaN results drop the unit (`mod(5px, 0px)` → `calc(NaN)`, not + // `calc(NaN * 1px)`). §10.12 unit-preserving form is a known divergence. + if (isNaN(result)) {return num(NaN);} + return fold.unit === '' ? num(result) : dim(result, fold.unit); +} + +/** + * @param {'mod' | 'rem'} name + * @param {number} a + * @param {number} b + * @return {number} + */ +function applyModRem(name, a, b) { + if (b === 0) {return NaN;} + if (!isFinite(a)) {return NaN;} + if (!isFinite(b)) { + // mod: result is NaN when A has opposite sign to B; otherwise A. + // rem: result is A regardless of signs. + if (name === 'mod' && a !== 0 && Math.sign(a) !== Math.sign(b)) {return NaN;} + return a; + } + return name === 'mod' + ? a - b * Math.floor(a / b) // sign follows divisor + : a - b * Math.trunc(a / b); // sign follows dividend (≡ JS %) +} + +module.exports = { simplifyModRem }; diff --git a/src/lib/simplify/pow.js b/src/lib/simplify/pow.js new file mode 100644 index 0000000..6968588 --- /dev/null +++ b/src/lib/simplify/pow.js @@ -0,0 +1,20 @@ +'use strict'; + +// §10.5 — pow is -only. + +const { num } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyPow(args) { + if (args.length !== 2 || args[0].type !== 'Num' || args[1].type !== 'Num') { + return { type: 'Call', name: 'pow', args }; + } + return num(Math.pow(args[0].value, args[1].value)); +} + +module.exports = { simplifyPow }; diff --git a/src/lib/simplify/product.js b/src/lib/simplify/product.js new file mode 100644 index 0000000..8e7c81d --- /dev/null +++ b/src/lib/simplify/product.js @@ -0,0 +1,118 @@ +'use strict'; + +const { mkSum, mkProduct, num, dim } = require('../node.js'); +const { tryCancelPair } = require('./cancel.js'); + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Product} Product + * @typedef {import('../node.js').ProductFactor} ProductFactor + * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn + */ + +/** + * @param {Product} product + * @param {SimplifyFn} simplify + * @return {Node} + */ +function simplifyProduct(product, simplify) { + let coeff = 1; + /** @type {{exponent: 1 | -1, value: number, unit: string}[]} */ + const dims = []; + /** @type {ProductFactor[]} */ + const opaque = []; + + /** + * @param {1 | -1} exponent + * @param {Node} n + * @return {void} + */ + function processFactor(exponent, n) { + if (n.type === 'Product') { + for (const inner of n.factors) { + processFactor( + /** @type {1 | -1} */ (exponent * inner.exponent), + inner.node + ); + } + return; + } + if (n.type === 'Num') { + if (exponent === 1) { + coeff *= n.value; + } else { + coeff /= n.value; // §10.9.1: 1/0 → ±Infinity, 0/0 → NaN per IEEE-754 + } + return; + } + if (n.type === 'Dim') { + dims.push({ exponent, value: n.value, unit: n.unit }); + return; + } + opaque.push({ exponent, node: n }); + } + + for (const f of product.factors) { + processFactor(f.exponent, simplify(f.node)); + } + + // §10.2 typed division. Higher-power cancellation (`px^2 / px`) is left + // unreduced — consumers don't rely on it and the spec doesn't require it. + const cancelled = tryCancelPair(dims); + if (cancelled !== null) { + coeff *= cancelled.factor; + } + const remainingDims = cancelled ? cancelled.remaining : dims; + + // §10.10 distributive multiplication: `0.5 * (100vw - 10px)` → `50vw - 5px`. + // Only distribute when every Sum term is Num/Dim — partial distribution + // over opaque terms matches neither the legacy implementation nor csstools. + if ( + remainingDims.length === 0 && + opaque.length === 1 && + opaque[0].exponent === 1 && + opaque[0].node.type === 'Sum' && + opaque[0].node.terms.every( + (t) => t.node.type === 'Num' || t.node.type === 'Dim' + ) + ) { + const sum = opaque[0].node; + const distributed = sum.terms.map((t) => ({ + sign: t.sign, + node: simplify( + mkProduct([ + { exponent: 1, node: num(coeff) }, + { exponent: 1, node: t.node }, + ]) + ), + })); + return mkSum(distributed); + } + + if ( + remainingDims.length === 1 && + remainingDims[0].exponent === 1 && + opaque.length === 0 + ) { + const d = remainingDims[0]; + return dim(coeff * d.value, d.unit); + } + + if (remainingDims.length === 0 && opaque.length === 0) { + return num(coeff); + } + + /** @type {ProductFactor[]} */ + const factors = []; + if (coeff !== 1) { + factors.push({ exponent: 1, node: num(coeff) }); + } + for (const d of remainingDims) { + factors.push({ exponent: d.exponent, node: dim(d.value, d.unit) }); + } + factors.push(...opaque); + + return mkProduct(factors); +} + +module.exports = { simplifyProduct }; diff --git a/src/lib/simplify/round.js b/src/lib/simplify/round.js new file mode 100644 index 0000000..5954680 --- /dev/null +++ b/src/lib/simplify/round.js @@ -0,0 +1,113 @@ +'use strict'; + +const { num, dim } = require('../node.js'); +const { foldConstArgs } = require('./fold.js'); + +/** @typedef {import('../node.js').Node} Node */ + +const ROUND_STRATEGIES = new Set(['nearest', 'up', 'down', 'to-zero']); +/** @typedef {'nearest' | 'up' | 'down' | 'to-zero'} RoundStrategy */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifyRound(args) { + /** @type {RoundStrategy} */ let strategy = 'nearest'; + let rest = args; + const first = args[0]; + if (first?.type === 'Ident') { + const n = first.name.toLowerCase(); + if (!ROUND_STRATEGIES.has(n)) { + // Unrecognized strategy ident — opaque rather than guessing intent. + return { type: 'Call', name: 'round', args }; + } + strategy = /** @type {RoundStrategy} */ (n); + rest = args.slice(1); + } + + /** @type {() => Node} */ + const passthrough = () => ({ + type: 'Call', + name: 'round', + args: + strategy === 'nearest' + ? rest + : [{ type: 'Ident', name: strategy }, ...rest], + }); + + // B omitted: defaults to 1 when A is ; else opaque. + const argsForFold = argsForRoundFold(rest); + const fold = argsForFold && foldConstArgs(argsForFold); + if (!fold) {return passthrough();} + + const [a, b] = /** @type {[number, number]} */ (fold.values); + // Spec §10.7.1 non-finite step: NaN propagates; both infinite cancels to + // NaN. Infinite step with finite A is strategy-dependent — the multiples + // of an infinite step are {-∞, 0, +∞}, so `up` (ceiling) lands on +∞ for + // positive A and `down` (floor) lands on -∞ for negative A; every other + // case folds to ±0 carrying A's sign. Infinite-A / finite-B falls through + // to applyRound, where floor*b===ceil*b===±∞ collapses back to A + // (§10.3.1 "result is the same infinity"). + if (isNaN(b)) {return num(NaN);} + if (!isFinite(b)) { + if (!isFinite(a)) {return num(NaN);} + let result; + if (strategy === 'up' && a > 0) { + result = Infinity; + } else if (strategy === 'down' && a < 0) { + result = -Infinity; + } else { + result = a < 0 || Object.is(a, -0) ? -0 : 0; + } + return fold.unit === '' ? num(result) : dim(result, fold.unit); + } + + const result = applyRound(strategy, a, b); + if (isNaN(result)) {return num(NaN);} + return fold.unit === '' ? num(result) : dim(result, fold.unit); +} + +/** + * @param {Node[]} args + * @return {Node[] | null} + */ +function argsForRoundFold(args) { + if (args.length === 2) {return args;} + if (args.length === 1 && args[0].type === 'Num') { + return [args[0], num(1)]; + } + return null; +} + +/** + * @param {RoundStrategy} strategy + * @param {number} a + * @param {number} b + * @return {number} + */ +function applyRound(strategy, a, b) { + if (b === 0) {return NaN;} + const q = a / b; + const c1 = Math.floor(q) * b; + const c2 = Math.ceil(q) * b; + // With negative B, floor*B > ceil*B; spec defines lower as closer to -∞. + const lower = Math.min(c1, c2); + const upper = Math.max(c1, c2); + if (lower === upper) {return a;} + switch (strategy) { + case 'up': + return upper; + case 'down': + return lower; + case 'to-zero': + return Math.abs(lower) <= Math.abs(upper) ? lower : upper; + case 'nearest': { + const dl = a - lower; + const du = upper - a; + return du <= dl ? upper : lower; // tie → upper (§10.3 line 978) + } + } +} + +module.exports = { simplifyRound }; diff --git a/src/lib/simplify/sign.js b/src/lib/simplify/sign.js new file mode 100644 index 0000000..388c97f --- /dev/null +++ b/src/lib/simplify/sign.js @@ -0,0 +1,22 @@ +'use strict'; + +const { num } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifySign(args) { + if (args.length !== 1) { + return { type: 'Call', name: 'sign', args }; + } + const a = args[0]; + if (a.type === 'Num') {return num(Math.sign(a.value));} + // %: sign is property-context-dependent (§10.6) — opaque. + if (a.type === 'Dim' && a.unit !== '%') {return num(Math.sign(a.value));} + return { type: 'Call', name: 'sign', args: [a] }; +} + +module.exports = { simplifySign }; diff --git a/src/lib/simplify/sqrt.js b/src/lib/simplify/sqrt.js new file mode 100644 index 0000000..43e1957 --- /dev/null +++ b/src/lib/simplify/sqrt.js @@ -0,0 +1,18 @@ +'use strict'; + +const { num } = require('../node.js'); + +/** @typedef {import('../node.js').Node} Node */ + +/** + * @param {Node[]} args + * @return {Node} + */ +function simplifySqrt(args) { + if (args.length !== 1 || args[0].type !== 'Num') { + return { type: 'Call', name: 'sqrt', args }; + } + return num(Math.sqrt(args[0].value)); +} + +module.exports = { simplifySqrt }; diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js new file mode 100644 index 0000000..fcc2be3 --- /dev/null +++ b/src/lib/simplify/sum.js @@ -0,0 +1,83 @@ +'use strict'; + +const { mkSum, num, dim } = require('../node.js'); +const { baseOf } = require('../type.js'); +const { mergeConvertibleBuckets } = require('./bucket.js'); + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').Sum} Sum + * @typedef {import('../node.js').SumTerm} SumTerm + * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn + * @typedef {import('./bucket.js').UnitBucket} UnitBucket + */ + +/** + * @param {Sum} sum + * @param {SimplifyFn} simplify + * @return {Node} + */ +function simplifySum(sum, simplify) { + // §10.10 two-phase dim handling: phase 1 buckets by exact unit (`1em + 1em` + // → `2em`); phase 2 merges convertible same-base buckets into the first- + // encountered unit. `100vh - 5rem - 10rem - 100px` → `-15rem` in phase 1, + // then vh/rem/px stay separate in phase 2 (none convert to each other). + let numTotal = 0; + /** @type {Map} */ + const byUnit = new Map(); + /** @type {SumTerm[]} */ + const opaque = []; + let bucketOrder = 0; + + /** + * @param {1 | -1} sign + * @param {Node} n + * @return {void} + */ + function processTerm(sign, n) { + if (n.type === 'Sum') { + for (const inner of n.terms) { + processTerm(/** @type {1 | -1} */ (sign * inner.sign), inner.node); + } + return; + } + if (n.type === 'Num') { + numTotal += sign * n.value; + return; + } + if (n.type === 'Dim') { + const key = n.unit.toLowerCase(); + const existing = byUnit.get(key); + if (existing) { + existing.total += sign * n.value; + } else { + byUnit.set(key, { + unit: n.unit, + total: sign * n.value, + base: baseOf(n.unit), + order: bucketOrder++, + }); + } + return; + } + opaque.push({ sign, node: n }); + } + + for (const t of sum.terms) { + processTerm(t.sign, simplify(t.node)); + } + + // mkSum drops zero-valued Nums, so pushing the numeric total + // unconditionally is harmless. Zero-valued unit buckets are kept for + // type info (WPT calc-serialization-002). + /** @type {SumTerm[]} */ + const terms = [{ sign: 1, node: num(numTotal) }]; + for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { + terms.push({ sign: 1, node: dim(bucket.total, bucket.unit) }); + } + terms.push(...opaque); + + return mkSum(terms); +} + +module.exports = { simplifySum }; diff --git a/src/lib/simplify/trig.js b/src/lib/simplify/trig.js new file mode 100644 index 0000000..ec560c3 --- /dev/null +++ b/src/lib/simplify/trig.js @@ -0,0 +1,40 @@ +'use strict'; + +// §10.4 — sin/cos/tan. is radians; dim is converted. + +const { num } = require('../node.js'); +const { baseOf, convert } = require('../type.js'); + +/** @typedef {import('../node.js').Node} Node */ + +const TRIG_OPS = /** @type {const} */ ({ + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, +}); + +/** + * @param {'sin' | 'cos' | 'tan'} name + * @param {Node[]} args + * @return {Node} + */ +function simplifyTrig(name, args) { + if (args.length !== 1) {return { type: 'Call', name, args };} + const a = args[0]; + /** @type {number | null} */ let radians = null; + if (a.type === 'Num') { + radians = a.value; + } else if (a.type === 'Dim' && a.unit !== '%' && baseOf(a.unit) === 'angle') { + // The `baseOf === 'angle'` check and the `inDeg !== null` guard below + // are observationally equivalent under current type tables (every + // angle unit has a TO_CANONICAL entry). Stryker flags both as + // equivalent-mutant survivors — keep them; they're load-bearing + // defense against future unit additions. + const inDeg = convert(a.value, a.unit, 'deg'); + if (inDeg !== null) {radians = (inDeg * Math.PI) / 180;} + } + if (radians === null) {return { type: 'Call', name, args };} + return num(TRIG_OPS[name](radians)); +} + +module.exports = { simplifyTrig }; diff --git a/src/lib/stringifier.js b/src/lib/stringifier.js deleted file mode 100644 index dc12cff..0000000 --- a/src/lib/stringifier.js +++ /dev/null @@ -1,106 +0,0 @@ -'use strict'; -const order = { - '*': 0, - '/': 0, - '+': 1, - '-': 1, -}; - -/** - * @param {number} value - * @param {number | false} prec - */ -function round(value, prec) { - if (prec !== false) { - const precision = Math.pow(10, prec); - return Math.round(value * precision) / precision; - } - return value; -} - -/** - * @param {number | false} prec - * @param {import('../parser').CalcNode} node - * - * @return {string} - */ -function stringify(node, prec) { - switch (node.type) { - case 'MathExpression': { - const { left, right, operator: op } = node; - let str = ''; - if (left.type === 'MathExpression' && order[op] < order[left.operator]) { - str += `(${stringify(left, prec)})`; - } else if (left.type === 'CalcKeyword') { - str += left.value; - } else { - str += stringify(left, prec); - } - - str += order[op] ? ` ${node.operator} ` : node.operator; - - if ( - right.type === 'MathExpression' && - order[op] < order[right.operator] - ) { - str += `(${stringify(right, prec)})`; - } else if (right.type === 'CalcKeyword') { - str += right.value; - } else { - str += stringify(right, prec); - } - - return str; - } - case 'Number': - return round(node.value, prec).toString(); - case 'Function': - return node.value.toString(); - case 'ParenthesizedExpression': - return `(${stringify(node.content, prec)})`; - case 'CalcKeyword': - return node.value; - default: - return round(node.value, prec) + node.unit; - } -} - -/** - * @param {string} calc - * @param {import('../parser').CalcNode} node - * @param {string} originalValue - * @param {{precision: number | false, warnWhenCannotResolve: boolean}} options - * @param {import("postcss").Result} result - * @param {import("postcss").ChildNode} item - * - * @returns {string} - */ -module.exports = function (calc, node, originalValue, options, result, item) { - let str = stringify(node, options.precision); - - const shouldPrintCalc = - node.type === 'MathExpression' || - node.type === 'Function' || - node.type === 'ParenthesizedExpression' || - node.type === 'CalcKeyword'; - - if (shouldPrintCalc) { - // if calc expression couldn't be resolved to a single value, re-wrap it as - // a calc() - if (node.type === 'ParenthesizedExpression') { - str = `${calc}${str}`; - } else { - str = `${calc}(${str})`; - } - - // if the warnWhenCannotResolve option is on, inform the user that the calc - // expression could not be resolved to a single value - if (options.warnWhenCannotResolve) { - result.warn('Could not reduce expression: ' + originalValue, { - plugin: 'postcss-calc', - node: item, - }); - } - } - return str; -}; diff --git a/src/lib/tokenizer.js b/src/lib/tokenizer.js new file mode 100644 index 0000000..793f161 --- /dev/null +++ b/src/lib/tokenizer.js @@ -0,0 +1,115 @@ +'use strict'; + +// Folds @csstools/css-tokenizer output into the calc() token subset. + +// @csstools/css-tokenizer is ESM-only and this package is CJS. require(esm) +// must happen at first use, not at module evaluation — a consumer's ESM +// graph may also link the tokenizer (other csstools plugins do), and a +// load-time require would hit ERR_REQUIRE_CYCLE_MODULE there. +/** @type {typeof import('@csstools/css-tokenizer') | undefined} */ +let cssTokenizer; + +/** + * @typedef {'number' | 'dimension' | 'ident' | 'punct' | 'eof'} TokenType + * @typedef {object} Token + * @property {TokenType} type + * @property {string} value + * @property {string} [unit] Present on `dimension` tokens; `%` for percentages. + * @property {number} pos + * @property {boolean} ws Whitespace immediately before — drives the §10.1 `+`/`-` rule. + */ + +const PUNCT_DELIMS = new Set(['+', '-', '*', '/']); + +const NUMERIC_RAW = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/; + +/** + * @param {string} input + * @return {Token[]} + */ +function tokenize(input) { + cssTokenizer ??= require('@csstools/css-tokenizer'); + const { tokenize: tokenizeCss, TokenType: CssType } = cssTokenizer; + /** @type {Token[]} */ + const tokens = []; + let ws = true; + + // CSS absorbs leading signs (`-5px` is one token); the parser expects + // punct sign + unsigned numeric, so split them back out. + /** + * @param {string} raw + * @param {string | undefined} unit + * @param {number} pos + * @return {void} + */ + function pushNumeric(raw, unit, pos) { + let value = /** @type {RegExpExecArray} */ (NUMERIC_RAW.exec(raw))[0]; + const sign = value[0]; + if (sign === '+' || sign === '-') { + tokens.push({ type: 'punct', value: sign, pos, ws }); + value = value.slice(1); + pos += 1; + ws = false; + } + if (unit === undefined) { + tokens.push({ type: 'number', value, pos, ws }); + } else { + tokens.push({ type: 'dimension', value, unit, pos, ws }); + } + ws = false; + } + + for (const t of tokenizeCss({ css: input })) { + switch (t[0]) { + case CssType.Whitespace: + case CssType.Comment: + ws = true; + continue; + case CssType.Number: + pushNumeric(t[1], undefined, t[2]); + continue; + case CssType.Dimension: + pushNumeric(t[1], t[4].unit, t[2]); + continue; + case CssType.Percentage: + pushNumeric(t[1], '%', t[2]); + continue; + case CssType.Ident: + tokens.push({ type: 'ident', value: t[4].value, pos: t[2], ws }); + break; + case CssType.Function: + tokens.push({ type: 'ident', value: t[4].value, pos: t[2], ws }); + tokens.push({ type: 'punct', value: '(', pos: t[2] + t[1].length - 1, ws: false }); + break; + case CssType.OpenParen: + tokens.push({ type: 'punct', value: '(', pos: t[2], ws }); + break; + case CssType.CloseParen: + tokens.push({ type: 'punct', value: ')', pos: t[2], ws }); + break; + case CssType.Comma: + tokens.push({ type: 'punct', value: ',', pos: t[2], ws }); + break; + case CssType.Delim: + if (!PUNCT_DELIMS.has(t[4].value)) { + throw new Error( + `Unexpected character "${t[4].value}" at position ${t[2]}` + ); + } + tokens.push({ type: 'punct', value: t[4].value, pos: t[2], ws }); + break; + case CssType.EOF: + tokens.push({ type: 'eof', value: '', pos: input.length, ws }); + break; + default: + throw new Error( + `Unexpected character "${t[1][0] ?? ''}" at position ${t[2]}` + ); + } + ws = false; + } + + return tokens; +} + +module.exports = { tokenize }; diff --git a/src/lib/transform.js b/src/lib/transform.js deleted file mode 100644 index 79add46..0000000 --- a/src/lib/transform.js +++ /dev/null @@ -1,109 +0,0 @@ -'use strict'; -const selectorParser = require('postcss-selector-parser'); -const valueParser = require('postcss-value-parser'); - -const { parser } = require('../parser.js'); - -const reducer = require('./reducer.js'); -const stringifier = require('./stringifier.js'); - -const MATCH_CALC = /((?:-(moz|webkit)-)?calc(?!-))/i; - -/** - * @param {string} value - * @param {{precision: number, warnWhenCannotResolve: boolean}} options - * @param {import("postcss").Result} result - * @param {import("postcss").ChildNode} item - */ -function transformValue(value, options, result, item) { - return valueParser(value) - .walk((node) => { - // skip anything which isn't a calc() function - if (node.type !== 'function' || !MATCH_CALC.test(node.value)) { - return; - } - - // stringify calc expression and produce an AST - const contents = valueParser.stringify(node.nodes); - const ast = parser.parse(contents); - - // reduce AST to its simplest form, that is, either to a single value - // or a simplified calc expression - const reducedAst = reducer(ast, options.precision); - - // stringify AST and write it back - /** @type {valueParser.Node} */ (node).type = 'word'; - node.value = stringifier( - node.value, - reducedAst, - value, - options, - result, - item - ); - - return false; - }) - .toString(); -} -/** - * @param {import("postcss-selector-parser").Selectors} value - * @param {{precision: number, warnWhenCannotResolve: boolean}} options - * @param {import("postcss").Result} result - * @param {import("postcss").ChildNode} item - */ -function transformSelector(value, options, result, item) { - return selectorParser((selectors) => { - selectors.walk((node) => { - // attribute value - // e.g. the "calc(3*3)" part of "div[data-size="calc(3*3)"]" - if (node.type === 'attribute' && node.value) { - node.setValue(transformValue(node.value, options, result, item)); - } - - // tag value - // e.g. the "calc(3*3)" part of "div:nth-child(2n + calc(3*3))" - if (node.type === 'tag') { - node.value = transformValue(node.value, options, result, item); - } - - return; - }); - }).processSync(value); -} - -/** - * @param {any} node - * @param {{precision: number, preserve: boolean, warnWhenCannotResolve: boolean}} options - * @param {'value'|'params'|'selector'} property - * @param {import("postcss").Result} result - */ -module.exports = (node, property, options, result) => { - let value; - - try { - value = - property === 'selector' - ? transformSelector(node[property], options, result, node) - : transformValue(node[property], options, result, node); - } catch (error) { - if (error instanceof Error) { - result.warn(error.message, { node }); - } else { - result.warn('Error', { node }); - } - return; - } - - // if the preserve option is enabled and the value has changed, write the - // transformed value into a cloned node which is inserted before the current - // node, preserving the original value. Otherwise, overwrite the original - // value. - if (options.preserve && node[property] !== value) { - const clone = node.clone(); - clone[property] = value; - node.parent.insertBefore(node, clone); - } else { - node[property] = value; - } -}; diff --git a/src/lib/type.js b/src/lib/type.js new file mode 100644 index 0000000..549a096 --- /dev/null +++ b/src/lib/type.js @@ -0,0 +1,105 @@ +'use strict'; + +// Spec: https://www.w3.org/TR/css-values-4/#calc-type-checking + +/** + * @typedef {'length' | 'angle' | 'time' | 'frequency' | 'resolution' | 'flex' | 'percentage'} BaseType + */ + +/** @type {Record} */ +const UNIT_TO_BASE = { + px: 'length', cm: 'length', mm: 'length', q: 'length', + in: 'length', pt: 'length', pc: 'length', + em: 'length', ex: 'length', ch: 'length', rem: 'length', + lh: 'length', rlh: 'length', ic: 'length', cap: 'length', + vw: 'length', vh: 'length', vmin: 'length', vmax: 'length', + vb: 'length', vi: 'length', + svw: 'length', svh: 'length', svmin: 'length', svmax: 'length', + svb: 'length', svi: 'length', + lvw: 'length', lvh: 'length', lvmin: 'length', lvmax: 'length', + lvb: 'length', lvi: 'length', + dvw: 'length', dvh: 'length', dvmin: 'length', dvmax: 'length', + dvb: 'length', dvi: 'length', + cqw: 'length', cqh: 'length', cqi: 'length', cqb: 'length', + cqmin: 'length', cqmax: 'length', + + deg: 'angle', grad: 'angle', rad: 'angle', turn: 'angle', + + s: 'time', ms: 'time', + + hz: 'frequency', khz: 'frequency', + + dpi: 'resolution', dpcm: 'resolution', dppx: 'resolution', x: 'resolution', + + fr: 'flex', + + '%': 'percentage', +}; + +/** + * @param {string} unit + * @return {BaseType | null} + */ +function baseOf(unit) { + return UNIT_TO_BASE[unit.toLowerCase()] ?? null; +} + +// Conversion factors to each family's canonical unit. Units NOT listed +// (em, rem, vw, cqw, fr, % …) share a base type with something convertible +// but can't resolve statically — the simplifier preserves them as separate +// summands rather than merging. +/** @type {Record} */ +const TO_CANONICAL = { + px: 1, + cm: 96 / 2.54, + mm: 96 / 25.4, + q: 96 / 101.6, + in: 96, + pt: 96 / 72, + pc: 16, + deg: 1, + grad: 0.9, + rad: 180 / Math.PI, + turn: 360, + s: 1, + ms: 0.001, + hz: 1, + khz: 1000, + dppx: 1, + dpi: 1 / 96, + dpcm: 2.54 / 96, + x: 1, + // flex / percentage: identity — only combinable with the same unit. + fr: 1, + '%': 1, +}; + +/** + * Convert a value within a single conversion family. Returns null when + * either unit is missing from the table (em/rem/vw need runtime context) + * or when the units belong to different base types. + * @param {number} value + * @param {string} from + * @param {string} to + * @return {number | null} + */ +function convert(value, from, to) { + const fromKey = from.toLowerCase(); + const toKey = to.toLowerCase(); + if (fromKey === toKey) { + return value; + } + const f = TO_CANONICAL[fromKey]; + const t = TO_CANONICAL[toKey]; + if (f === undefined || t === undefined) { + return null; + } + // Cross-family guard: `px` and `s` both have entry 1, so without this + // `convert(1, 'px', 's')` would silently return 1. + if (UNIT_TO_BASE[fromKey] !== UNIT_TO_BASE[toKey]) { + return null; + } + return (value * f) / t; +} + +module.exports = { baseOf, convert }; diff --git a/src/parser.d.ts b/src/parser.d.ts deleted file mode 100644 index 1127d75..0000000 --- a/src/parser.d.ts +++ /dev/null @@ -1,89 +0,0 @@ -export interface MathExpression { - type: 'MathExpression'; - right: CalcNode; - left: CalcNode; - operator: '*' | '+' | '-' | '/'; -} - -export interface ParenthesizedExpression { - type: 'ParenthesizedExpression'; - content: CalcNode; -} - -export interface DimensionExpression { - type: - | 'LengthValue' - | 'AngleValue' - | 'TimeValue' - | 'FrequencyValue' - | 'PercentageValue' - | 'ResolutionValue' - | 'EmValue' - | 'ExValue' - | 'ChValue' - | 'RemValue' - | 'VhValue' - | 'SvhValue' - | 'LvhValue' - | 'DvhValue' - | 'VwValue' - | 'SvwValue' - | 'LvwValue' - | 'DvwValue' - | 'VminValue' - | 'SvminValue' - | 'LvminValue' - | 'DvminValue' - | 'VmaxValue' - | 'SvmaxValue' - | 'LvmaxValue' - | 'DvmaxValue' - | 'VbValue' - | 'SvbValue' - | 'LvbValue' - | 'DvbValue' - | 'ViValue' - | 'SviValue' - | 'LviValue' - | 'DviValue' - | 'CqwValue' - | 'CqhValue' - | 'CqbValue' - | 'CqiValue' - | 'CqminValue' - | 'CqmaxValue' - | 'LhValue' - | 'RlhValue'; - value: number; - unit: string; -} - -export interface NumberExpression { - type: 'Number'; - value: number; -} - -export interface FunctionExpression { - type: 'Function'; - value: string; -} - -export interface CalcKeywordExpression { - type: 'CalcKeyword'; - value: string; -} - -export type ValueExpression = DimensionExpression | NumberExpression; - -export type CalcNode = - | MathExpression - | ValueExpression - | FunctionExpression - | ParenthesizedExpression - | CalcKeywordExpression; - -export interface Parser { - parse: (arg: string) => CalcNode; -} - -export const parser: Parser; diff --git a/stryker.conf.mjs b/stryker.conf.mjs new file mode 100644 index 0000000..c396841 --- /dev/null +++ b/stryker.conf.mjs @@ -0,0 +1,32 @@ +// Stryker mutation testing config. +// +// Mutates the core pipeline (simplify + serialize + node constructors) and +// reports the kill rate. A surviving mutant means the test suite would +// accept a wrong implementation — i.e. a real coverage hole. +// +// We use the `command` runner since our test harness is `node --test` via +// tsx. Each mutant runs the whole pratt suite; the suite is fast enough +// (<400ms) that this is practical. + +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +export default { + testRunner: 'command', + commandRunner: { + command: 'node --test', + }, + mutate: [ + 'src/lib/simplify.js', + 'src/lib/serialize.js', + 'src/lib/node.js', + ], + ignorePatterns: ['node_modules', 'types'], + // Mutation score targets — anything below 70 fails CI. + thresholds: { high: 85, low: 70, break: 70 }, + reporters: ['clear-text', 'html', 'progress', 'json'], + htmlReporter: { fileName: 'reports/mutation/mutation.html' }, + jsonReporter: { fileName: 'reports/mutation/mutation.json' }, + concurrency: 4, + timeoutMS: 30000, + // Mutant categories worth including / skipping: + disableTypeChecks: true, +}; diff --git a/test/conformance/corpus.test.mjs b/test/conformance/corpus.test.mjs new file mode 100644 index 0000000..406f341 --- /dev/null +++ b/test/conformance/corpus.test.mjs @@ -0,0 +1,150 @@ +// Real-world corpus test. +// +// Inputs are every unique `calc(...)` expression extracted from cssnano's +// integration CSS fixtures (Bootstrap, Bulma, Foundation, Milligram, +// Picnic, Semantic UI, Turret, UIkit). The corpus is committed under +// `corpus/` so the test is self-contained — no sibling-repo dependency. +// +// For each expression we run both our pipeline and `@csstools/css-calc`, +// canonicalize the outputs through our parser at a shared precision, and +// assert they agree. Any divergence is either a real bug or a known +// design choice documented in `KNOWN_DIVERGENCES`. +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { calc as csstoolsCalc } from '@csstools/css-calc'; +import { out } from '../helpers/out.mjs'; +const CORPUS_DIR = fileURLToPath(new URL('../corpus/', import.meta.url)); +const COMPARE_PRECISION = 10; +function ourOut(input) { + try { + return out(input, { precision: COMPARE_PRECISION }); + } catch { + return null; + } +} +function theirOut(input) { + try { + const r = csstoolsCalc(input); + return typeof r === 'string' ? r : null; + } catch { + return null; + } +} +/** + * Documented divergences from csstools that we accept. Each entry is an + * INPUT string; the comment explains the chosen behavior. Adding a case + * here means the design choice is deliberate — not a workaround. + */ +const KNOWN_DIVERGENCES = new Set([ + // Mixed-unit angle sum: when an inverse trig function output (radians) + // is summed with degrees, we fold to a single deg-unit constant + // (`atan(.5) + 90deg` → `116.5650511771deg`); csstools keeps the rad+deg + // sum un-folded. Both outputs represent the same angle. Our choice + // matches the rest of our angle-serialization (degrees), and once the + // numeric folding is done the sum can't be expressed without a unit + // choice anyway. + 'calc(atan(.5) + 90deg - (var(--dir)*90deg))', + // Emoji/math-symbol custom properties: the current CSS Syntax draft + // excludes these code points from idents, so `--➕` splits and we warn + + // preserve; css-calc silently passes through. Same output either way. + 'calc(1 / var(--√𝟤))', + 'calc(var(--➕) * -1)', + 'calc(var(--➕) * var(--✖️))', + 'calc(var(--➖) * var(--✖️))', +]); +function runLibrary(lib, calcs) { + const result = { + lib, + total: calcs.length, + agree: 0, + bothFailed: 0, + divergences: [], + }; + for (const input of calcs) { + const ours = ourOut(input); + const theirs = theirOut(input); + if (ours === null && theirs === null) { + result.bothFailed++; + continue; + } + if (ours === null || theirs === null) { + if (!KNOWN_DIVERGENCES.has(input)) { + result.divergences.push({ + input, + ours: ours ?? '', + theirs: theirs ?? '', + }); + } + continue; + } + if (ours === theirs) { + result.agree++; + continue; + } + const canonicalTheirs = ourOut(theirs); + if (canonicalTheirs === null) { + // csstools produced something our parser couldn't read — rare. + if (!KNOWN_DIVERGENCES.has(input)) { + result.divergences.push({ input, ours, theirs }); + } + continue; + } + if (ours === canonicalTheirs) { + result.agree++; + continue; + } + if (!KNOWN_DIVERGENCES.has(input)) { + result.divergences.push({ input, ours, theirs }); + } + } + return result; +} +const libraries = readdirSync(CORPUS_DIR) + .filter((f) => f.endsWith('.txt')) + .sort((a, b) => a.localeCompare(b)); +const results = []; +for (const file of libraries) { + const lib = file.replace(/\.txt$/, ''); + const calcs = readFileSync(join(CORPUS_DIR, file), 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + results.push(runLibrary(lib, calcs)); +} +// --- Per-library tests ----------------------------------------------------- +for (const r of results) { + test(`corpus: ${r.lib} — ${r.total} expressions`, () => { + if (r.divergences.length > 0) { + const sample = r.divergences + .slice(0, 5) + .map( + (d) => + ` input: ${d.input}\n ours: ${d.ours}\n theirs: ${d.theirs}` + ) + .join('\n\n'); + assert.fail( + `${r.divergences.length} / ${r.total} diverge from csstools in ${r.lib} ` + + `(showing first 5):\n\n${sample}` + ); + } + assert.ok(true, `${r.agree}/${r.total} agree`); + }); +} +// --- Summary -------------------------------------------------------------- +test('corpus: overall summary', () => { + const total = results.reduce((a, r) => a + r.total, 0); + const agree = results.reduce((a, r) => a + r.agree, 0); + const diverge = results.reduce((a, r) => a + r.divergences.length, 0); + const both = results.reduce((a, r) => a + r.bothFailed, 0); + console.log( + `\n corpus totals: ${agree}/${total} agree, ${diverge} diverge, ${both} both-failed` + ); + assert.equal( + diverge, + 0, + `${diverge} undocumented divergences across the corpus` + ); +}); diff --git a/test/conformance/csstools.test.mjs b/test/conformance/csstools.test.mjs new file mode 100644 index 0000000..598f950 --- /dev/null +++ b/test/conformance/csstools.test.mjs @@ -0,0 +1,394 @@ +// Cribbed from @csstools/css-calc test corpus: +// https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc/test +// +// Each test cites its source file. Cases selected where our pipeline +// produces the same output as csstools. Deliberately excluded: +// - csstools `globals` option (variable substitution) — not in our scope +// - relative-color math (`rgb(from ...)`) — out of scope +// - exponential family (pow/sqrt/hypot/log/exp) — not yet implemented +// - cases where floating-point serialization precision differs (we use +// `precision: false` to emit full-float, but csstools occasionally +// rounds at ~15 significant figures in its own way) +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out as pipeline } from '../helpers/out.mjs'; +/** Full-precision output, matching csstools' default. */ +const out = (input) => pipeline(input, { precision: false }); +// --- basic/test.mjs ------------------------------------------------------- +test('csstools basic: number multiplication', () => { + assert.equal(out('calc(10 * 2)'), '20'); +}); +test('csstools basic: left-associative division', () => { + assert.equal(out('calc(15 / 5 / 3)'), '1'); +}); +test('csstools basic: parenthesized right-hand division', () => { + assert.equal(out('calc(15 / (5 / 3))'), '9'); +}); +test('csstools basic: precedence in mixed + / *', () => { + assert.equal(out('calc(2 * 3 + 7 * 5)'), '41'); +}); +test('csstools basic: nested parens honored', () => { + assert.equal(out('calc(((2 * 3) + 7) * 5)'), '65'); +}); +test('csstools basic: simple addition of numbers', () => { + assert.equal(out('calc(2 + 3)'), '5'); +}); +test('csstools basic: simple subtraction of numbers', () => { + assert.equal(out('calc(10 - 4)'), '6'); +}); +// --- wpt/calc-unit-analysis.mjs ------------------------------------------ +test('csstools unit-analysis: calc(0) → 0', () => { + assert.equal(out('calc(0)'), '0'); +}); +test('csstools unit-analysis: calc(0px) → 0px', () => { + assert.equal(out('calc(0px)'), '0px'); +}); +// DIVERGE: csstools preserves source term order; we emit resolvables +// (numbers/same-unit dims) first. Both are valid per §10.12 (which actually +// specifies a third order: numbers → percentages → dims-ASCII-sorted). +test('csstools unit-analysis: length + number preserved as a sum', () => { + // csstools: `calc(1px + 2)`. Ours reorders. + assert.equal(out('calc(1px + 2)'), 'calc(2 + 1px)'); +}); +test('csstools unit-analysis: number + length preserved as a sum', () => { + assert.equal(out('calc(2 + 1px)'), 'calc(2 + 1px)'); +}); +test('csstools unit-analysis: length - number preserved as a sum', () => { + // csstools: `calc(1px - 2)`. Ours: `calc(-2 + 1px)` (reorder pushes the + // negative number to the front). + assert.equal(out('calc(1px - 2)'), 'calc(-2 + 1px)'); +}); +test('csstools unit-analysis: number - length preserved as a sum', () => { + assert.equal(out('calc(2 - 1px)'), 'calc(2 - 1px)'); +}); +test('csstools unit-analysis: length * number folds', () => { + assert.equal(out('calc(2px * 2)'), '4px'); +}); +test('csstools unit-analysis: number * length folds', () => { + assert.equal(out('calc(2 * 2px)'), '4px'); +}); +test('csstools unit-analysis: length * length preserved (unit^2 not expressible)', () => { + assert.equal(out('calc(2px * 1px)'), 'calc(2px * 1px)'); +}); +// --- wpt/calc-time-values.mjs (same-unit + cross-unit with exact math) --- +test('csstools time: s + s', () => { + assert.equal(out('calc(4s + 1s)'), '5s'); +}); +test('csstools time: ms + ms', () => { + assert.equal(out('calc(4ms + 1ms)'), '5ms'); +}); +test('csstools time: s - s', () => { + assert.equal(out('calc(4s - 1s)'), '3s'); +}); +test('csstools time: number * s', () => { + assert.equal(out('calc(4 * 1s)'), '4s'); +}); +test('csstools time: s * number', () => { + assert.equal(out('calc(1s * 4)'), '4s'); +}); +test('csstools time: s / number', () => { + assert.equal(out('calc(8s / 4)'), '2s'); +}); +test('csstools time: s / s → unitless', () => { + assert.equal(out('calc(8s / 2s)'), '4'); +}); +// --- wpt/calc-angle-values.mjs (same-unit cases — exact math) ------------ +test('csstools angle: deg + deg', () => { + assert.equal(out('calc(45deg + 45deg)'), '90deg'); +}); +test('csstools angle: rad + rad', () => { + assert.equal(out('calc(45rad + 45rad)'), '90rad'); +}); +test('csstools angle: grad + grad', () => { + assert.equal(out('calc(45grad + 45grad)'), '90grad'); +}); +test('csstools angle: turn + turn', () => { + assert.equal(out('calc(0.5turn + 0.5turn)'), '1turn'); +}); +// --- wpt/minmax-percentage-computed.mjs ---------------------------------- +// csstools preserves percent inside min/max/clamp unconditionally. +test('csstools minmax-%: single-arg min kept', () => { + assert.equal(out('min(1%)'), 'min(1%)'); +}); +test('csstools minmax-%: single-arg max kept', () => { + assert.equal(out('max(1%)'), 'max(1%)'); +}); +test('csstools minmax-%: nested min/max with percent kept', () => { + assert.equal(out('min(20%, max(10%, 15%))'), 'min(20%, max(10%, 15%))'); +}); +test('csstools minmax-%: sum around min/max percent kept intact', () => { + // DIVERGE (order): csstools `calc(min(10%, 20%) + 5%)` → same. + // Ours emits resolvable `5%` first. + assert.equal(out('calc(min(10%, 20%) + 5%)'), 'calc(5% + min(10%, 20%))'); +}); +// --- wpt/minmax-integer-computed.mjs (number-typed min/max) -------------- +test('csstools minmax-int: min of integers folds', () => { + assert.equal(out('min(1, 2, 3)'), '1'); +}); +test('csstools minmax-int: max of integers folds', () => { + assert.equal(out('max(1, 2, 3)'), '3'); +}); +test('csstools minmax-int: single-arg min of number folds', () => { + assert.equal(out('min(1)'), '1'); +}); +// --- wpt/minmax-time-computed.mjs (same-unit cases) ---------------------- +test('csstools minmax-time: min of seconds', () => { + assert.equal(out('min(1s, 2s, 3s)'), '1s'); +}); +test('csstools minmax-time: max of seconds', () => { + assert.equal(out('max(1s, 2s, 3s)'), '3s'); +}); +// --- wpt/max-20-arguments.mjs -------------------------------------------- +test('csstools max-20: max with many numeric args folds', () => { + assert.equal( + out( + 'max(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)' + ), + '20' + ); +}); +// --- wpt/calc-in-calc.mjs ------------------------------------------------ +test('csstools calc-in-calc: nested calc flattens', () => { + assert.equal(out('calc(calc(1))'), '1'); +}); +test('csstools calc-in-calc: double-nested calc flattens', () => { + assert.equal(out('calc(calc(calc(2px)))'), '2px'); +}); +test('csstools calc-in-calc: nested calc with sum', () => { + assert.equal(out('calc(calc(1px + 2px))'), '3px'); +}); +// --- wpt/clamp-length-computed.mjs (same-unit, fully-resolvable) --------- +test('csstools clamp: middle value selected', () => { + assert.equal(out('clamp(1px, 2px, 3px)'), '2px'); +}); +test('csstools clamp: min cap applied', () => { + assert.equal(out('clamp(5px, 2px, 10px)'), '5px'); +}); +test('csstools clamp: max cap applied', () => { + assert.equal(out('clamp(1px, 10px, 5px)'), '5px'); +}); +// --- basic/none-in-clamp.mjs (subset) ------------------------------------ +// clamp(none, ...) uses keyword `none` as unbounded. csstools supports this; +// we treat `none` as an opaque ident, so these preserve. +test('csstools none-in-clamp: none as lower bound preserved', () => { + assert.equal(out('clamp(none, 10px, 20px)'), 'clamp(none, 10px, 20px)'); +}); +test('csstools none-in-clamp: none as upper bound preserved', () => { + assert.equal(out('clamp(1px, 10px, none)'), 'clamp(1px, 10px, none)'); +}); +// --- wpt/invalid.mjs (subset our tokenizer/parser rejects) --------------- +test('csstools invalid: empty calc throws', () => { + assert.throws(() => out('calc()'), /takes exactly one argument/); +}); +test('csstools invalid: trailing operator throws', () => { + // §10.1: `+`/`-` must be surrounded by whitespace. The trailing `+` + // is followed by `)` without a space, which now throws at the + // strict-whitespace check. + assert.throws( + () => out('calc(1 +)'), + /must be surrounded by whitespace|Unexpected token/ + ); +}); +test('csstools invalid: lonely binary op throws', () => { + assert.throws(() => out('calc(/)'), /Unexpected token/); +}); +// --- @csstools/css-calc round/mod/rem/abs/sign fixtures ------------------ +// Cribbed from packages/css-calc/test for the stepped/sign-related suite. +test('csstools round: default strategy (nearest)', () => { + assert.equal(out('round(15, 10)'), '20'); + assert.equal(out('round(14, 10)'), '10'); +}); +test('csstools round: dim A and B in same family', () => { + assert.equal(out('round(15px, 10px)'), '20px'); +}); +test('csstools round: each strategy', () => { + assert.equal(out('round(up, 1.1, 1)'), '2'); + assert.equal(out('round(down, 1.9, 1)'), '1'); + assert.equal(out('round(to-zero, -1.9, 1)'), '-1'); + assert.equal(out('round(nearest, 1.5, 1)'), '2'); +}); +test('csstools round: B omitted for A', () => { + assert.equal(out('round(3.7)'), '4'); +}); +test('csstools round: opaque var() preserved', () => { + assert.equal(out('round(var(--x), 10)'), 'round(var(--x), 10)'); +}); +test('csstools mod: spec examples', () => { + assert.equal(out('mod(18, 5)'), '3'); + assert.equal(out('mod(-18, 5)'), '2'); + assert.equal(out('mod(18, -5)'), '-2'); +}); +test('csstools rem: spec examples', () => { + assert.equal(out('rem(18, 5)'), '3'); + assert.equal(out('rem(-18, 5)'), '-3'); + assert.equal(out('rem(18, -5)'), '3'); +}); +test('csstools mod/rem: dim args fold', () => { + assert.equal(out('mod(18px, 5px)'), '3px'); + assert.equal(out('rem(18px, 5px)'), '3px'); +}); +test('csstools abs: number and dim', () => { + assert.equal(out('abs(-5)'), '5'); + assert.equal(out('abs(-5px)'), '5px'); + assert.equal(out('abs(5em)'), '5em'); +}); +test('csstools abs: opaque preserves', () => { + assert.equal(out('abs(var(--x))'), 'abs(var(--x))'); +}); +test('csstools sign: number, dim, opaque', () => { + assert.equal(out('sign(-5)'), '-1'); + assert.equal(out('sign(5)'), '1'); + assert.equal(out('sign(0)'), '0'); + assert.equal(out('sign(-5px)'), '-1'); + assert.equal(out('sign(var(--x))'), 'sign(var(--x))'); +}); +test('csstools round: type mismatch → opaque', () => { + assert.equal(out('round(1px, 1deg)'), 'round(1px, 1deg)'); +}); +test('csstools mod/rem: type mismatch → opaque', () => { + assert.equal(out('mod(1px, 1deg)'), 'mod(1px, 1deg)'); + assert.equal(out('rem(1px, 1deg)'), 'rem(1px, 1deg)'); +}); +test('csstools round: cross-family conversion (in/px)', () => { + // 1in = 96px exactly; round(96px, 24px) = 96px = 1in (first unit wins). + assert.equal(out('round(1in, 24px)'), '1in'); +}); +test('csstools mod: cross-family time (1s, 100ms)', () => { + // 1s = 1000ms; mod(1000ms, 100ms) = 0ms; result in first unit (s) → 0s. + assert.equal(out('mod(1s, 100ms)'), '0s'); +}); +// --- trig/test.mjs (§10.4) ----------------------------------------------- +// +// `out` here uses precision: false, so floating-point artifacts that the +// default-precision unit suite swallows show through here as the literal +// JS strings (e.g. cos(60deg) = 0.5000000000000001). +test('csstools trig: sin(0) → 0', () => { + assert.equal(out('sin(0)'), '0'); +}); +test('csstools trig: cos(0) → 1', () => { + assert.equal(out('cos(0)'), '1'); +}); +test('csstools trig: tan(0) → 0', () => { + assert.equal(out('tan(0)'), '0'); +}); +test('csstools trig: sin(90deg) → 1', () => { + assert.equal(out('sin(90deg)'), '1'); +}); +test('csstools trig: cos(180deg) → -1', () => { + assert.equal(out('cos(180deg)'), '-1'); +}); +test('csstools trig: cos(60deg) → 0.5000000000000001 (full precision)', () => { + assert.equal(out('cos(60deg)'), '0.5000000000000001'); +}); +test('csstools trig: tan(45deg) → 0.9999999999999999 (full precision)', () => { + assert.equal(out('tan(45deg)'), '0.9999999999999999'); +}); +test('csstools trig: sin(pi) → 1.2246467991473532e-16 (full precision)', () => { + assert.equal(out('sin(pi)'), '1.2246467991473532e-16'); +}); +test('csstools trig: sin(0.5turn) → 1.2246467991473532e-16', () => { + assert.equal(out('sin(0.5turn)'), '1.2246467991473532e-16'); +}); +test('csstools trig: bare-number arg is radians — sin(pi / 2) → 1', () => { + assert.equal(out('sin(pi / 2)'), '1'); +}); +test('csstools trig: var() arg → opaque', () => { + assert.equal(out('sin(var(--x))'), 'sin(var(--x))'); +}); +test('csstools trig: length arg → opaque (must be number or angle)', () => { + assert.equal(out('sin(10px)'), 'sin(10px)'); +}); +test('csstools inverse-trig: asin(0) → 0deg', () => { + assert.equal(out('asin(0)'), '0deg'); +}); +test('csstools inverse-trig: asin(1) → 90deg', () => { + assert.equal(out('asin(1)'), '90deg'); +}); +test('csstools inverse-trig: asin(-1) → -90deg', () => { + assert.equal(out('asin(-1)'), '-90deg'); +}); +test('csstools inverse-trig: asin(0.5) → 30.000000000000004deg', () => { + assert.equal(out('asin(0.5)'), '30.000000000000004deg'); +}); +test('csstools inverse-trig: acos(1) → 0deg (zero-valued angle keeps unit)', () => { + assert.equal(out('acos(1)'), '0deg'); +}); +test('csstools inverse-trig: acos(-1) → 180deg', () => { + assert.equal(out('acos(-1)'), '180deg'); +}); +test('csstools inverse-trig: atan(1) → 45deg (exact in JS)', () => { + assert.equal(out('atan(1)'), '45deg'); +}); +test('csstools inverse-trig: atan(infinity) → 90deg', () => { + assert.equal(out('atan(infinity)'), '90deg'); +}); +test('csstools inverse-trig: dim arg → opaque (asin/acos/atan need )', () => { + assert.equal(out('asin(45deg)'), 'asin(45deg)'); +}); +test('csstools atan2: (0, 1) → 0deg', () => { + assert.equal(out('atan2(0, 1)'), '0deg'); +}); +test('csstools atan2: (1, 0) → 90deg', () => { + assert.equal(out('atan2(1, 0)'), '90deg'); +}); +test('csstools atan2: (1, 1) → 45deg', () => { + assert.equal(out('atan2(1, 1)'), '45deg'); +}); +test('csstools atan2: (-1, -1) → -135deg', () => { + assert.equal(out('atan2(-1, -1)'), '-135deg'); +}); +test('csstools atan2: cross-unit-same-base (1in, 96px) → 45deg', () => { + assert.equal(out('atan2(1in, 96px)'), '45deg'); +}); +test('csstools atan2: type mismatch → opaque', () => { + assert.equal(out('atan2(1px, 1deg)'), 'atan2(1px, 1deg)'); +}); +test('csstools atan2: percentages → opaque', () => { + assert.equal(out('atan2(50%, 50%)'), 'atan2(50%, 50%)'); +}); +// --- §10.5 exponential family fixtures ------------------------------- +test('csstools pow: pow(2, 3) → 8', () => { + assert.equal(out('pow(2, 3)'), '8'); +}); +test('csstools pow: pow(8, 1 / 3) ≈ 2', () => { + // csstools agrees on the cube-root identity within FP precision. + const got = parseFloat(out('pow(8, 1 / 3)')); + assert.ok(Math.abs(got - 2) < 1e-9, `got ${got}`); +}); +test('csstools sqrt: sqrt(16) → 4', () => { + assert.equal(out('sqrt(16)'), '4'); +}); +test('csstools sqrt: sqrt(0) → 0', () => { + assert.equal(out('sqrt(0)'), '0'); +}); +test('csstools exp: exp(0) → 1', () => { + assert.equal(out('exp(0)'), '1'); +}); +test('csstools log: log(8, 2) → 3', () => { + assert.equal(out('log(8, 2)'), '3'); +}); +test('csstools log: natural log of e → 1', () => { + assert.equal(out('log(e)'), '1'); +}); +test('csstools hypot: hypot(3, 4) → 5', () => { + assert.equal(out('hypot(3, 4)'), '5'); +}); +test('csstools hypot: hypot(3px, 4px) → 5px', () => { + assert.equal(out('hypot(3px, 4px)'), '5px'); +}); +test('csstools hypot: single arg passes through as abs', () => { + assert.equal(out('hypot(-2em)'), '2em'); +}); +// --- §10.13 degenerate-number fixtures ------------------------------- +test('csstools degenerate: calc(infinity) round-trips', () => { + assert.equal(out('calc(infinity)'), 'calc(infinity)'); +}); +test('csstools degenerate: division by zero produces calc(infinity * 1px)', () => { + assert.equal(out('calc(1px / 0)'), 'calc(infinity * 1px)'); +}); +test('csstools degenerate: NaN canonical casing on output', () => { + assert.equal(out('calc(NaN)'), 'calc(NaN)'); +}); +test('csstools degenerate: subtracting infinities → NaN', () => { + assert.equal(out('calc(infinity - infinity)'), 'calc(NaN)'); +}); diff --git a/test/conformance/invalid-corpus.test.mjs b/test/conformance/invalid-corpus.test.mjs new file mode 100644 index 0000000..9242966 --- /dev/null +++ b/test/conformance/invalid-corpus.test.mjs @@ -0,0 +1,22 @@ +// Parser-only resilience test for spec-strict-rejected real-world inputs. +// +// These are calc() expressions our parser rejects because they violate the +// CSS spec — most often missing whitespace around `+`/`-` (browsers also +// reject these), mustache template placeholders that leaked into CSS files, +// malformed parens, semicolons inside calc, etc. csstools/css-calc accepts +// them leniently, which is why they show up as "we throw / they accept" +// divergences. The split-corpus script routes them here so the +// csstools-comparison conformance suite stays meaningful. +// +// Goal: every input must throw a real synchronous `Error`. No hangs, no +// non-Error throws. +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runCorpus, assertResilience } from '../helpers/resilience.mjs'; +const r = runCorpus( + fileURLToPath(new URL('../corpus/github/invalid.txt', import.meta.url)) +); +test(`invalid corpus: ${r.total} expressions parse-or-throw cleanly`, () => { + assertResilience(r, 'invalid', assert); +}); diff --git a/test/conformance/preprocessor-corpus.test.mjs b/test/conformance/preprocessor-corpus.test.mjs new file mode 100644 index 0000000..d651c7a --- /dev/null +++ b/test/conformance/preprocessor-corpus.test.mjs @@ -0,0 +1,13 @@ +// Parser-only resilience test against real-world preprocessor (SCSS/Less) +// `calc(...)` expressions harvested from public GitHub repos. Asserts every +// input parses or throws cleanly — no hangs, no non-Error throws. +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runCorpus, assertResilience } from '../helpers/resilience.mjs'; +const r = runCorpus( + fileURLToPath(new URL('../corpus/github/preprocessor.txt', import.meta.url)) +); +test(`preprocessor corpus: ${r.total} expressions parse-or-throw cleanly`, () => { + assertResilience(r, 'preprocessor', assert); +}); diff --git a/test/conformance/wpt.test.mjs b/test/conformance/wpt.test.mjs new file mode 100644 index 0000000..263af4b --- /dev/null +++ b/test/conformance/wpt.test.mjs @@ -0,0 +1,490 @@ +// WPT (web-platform-tests) subset cribbed from: +// https://github.com/web-platform-tests/wpt/tree/master/css/css-values +// +// Each test cites its source file. Cases selected where our output +// matches the spec-defined simplified form without requiring: +// - Chrome/Firefox's canonical reordering of sum terms (§10.12 step 4), +// - eager normalization of absolute length units to px (a browser +// serialization choice, not a spec requirement for calc()), +// - infinity / NaN serialization (covered when full IEEE-754 fold lands). +// +// Trig (§10.4: sin/cos/tan/asin/acos/atan/atan2) is covered below; the +// exponential family (pow/sqrt/hypot/log/exp) is a planned follow-up. +// +// Divergences are documented with `DIVERGE:` comments. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out } from '../helpers/out.mjs'; +// --- calc-serialization.html --------------------------------------------- +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization.html +test('WPT calc-serialization: single negative length preserved', () => { + // Input: `calc(-10px)` → WPT expects `calc(-10px)`. + // DIVERGE: we unwrap single values to bare dimensions. + assert.equal(out('calc(-10px)'), '-10px'); +}); +test('WPT calc-serialization: resolvable + opaque kept as a sum', () => { + // Input: `calc(10px + 1vmin)` → WPT: `calc(10px + 1vmin)` (same order). + assert.equal(out('calc(10px + 1vmin)'), 'calc(10px + 1vmin)'); +}); +// --- minmax-length-serialize.html ---------------------------------------- +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/minmax-length-serialize.html +test('WPT minmax-length: single-arg min folds', () => { + // WPT specified: `calc(1px)`; our output unwraps to `1px`. + assert.equal(out('min(1px)'), '1px'); +}); +test('WPT minmax-length: single-arg max folds', () => { + assert.equal(out('max(1px)'), '1px'); +}); +test('WPT minmax-length: unit case normalized to lowercase', () => { + // Spec §10.12: `1Q` serializes as `1q`, `1PX` as `1px`. + assert.equal(out('min(1PX)'), '1px'); +}); +test('WPT minmax-length: min() preserved when arg types mix', () => { + // WPT: `min(1px, 1em)` stays `min(1px, 1em)` (em is relative). + assert.equal(out('min(1px, 1em)'), 'min(1px, 1em)'); +}); +test('WPT minmax-length: max folds when all args share a unit', () => { + // WPT (same unit): `max(1px, 2px, 3px)` → `3px`. + assert.equal(out('max(1px, 2px, 3px)'), '3px'); +}); +// --- calc-in-calc.html --------------------------------------------------- +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-in-calc.html +test('WPT calc-in-calc: outer calc() flattens inner calc()', () => { + assert.equal(out('calc(calc(100%))'), '100%'); +}); +test('WPT calc-in-calc: nested calc() with sum', () => { + assert.equal(out('calc(calc(1px + 2px) + 3px)'), '6px'); +}); +test('WPT calc-in-calc: doubly-nested calc', () => { + assert.equal(out('calc(calc(calc(5px)))'), '5px'); +}); +// --- calc-catch-divide-by-0.html (now §10.9.1 IEEE-754 form) ------------ +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-catch-divide-by-0.html +test('WPT divide-by-zero: 100px / 0 → calc(infinity * 1px)', () => { + assert.equal(out('calc(100px / 0)'), 'calc(infinity * 1px)'); +}); +test('WPT divide-by-zero: 100px / (2 - 2) → calc(infinity * 1px)', () => { + assert.equal(out('calc(100px / (2 - 2))'), 'calc(infinity * 1px)'); +}); +// --- calc-typed-arithmetic-parsing (implied from spec §10.2) ------------- +test('WPT typed-arith: / ', () => { + assert.equal(out('calc(10px / 2px)'), '5'); +}); +test('WPT typed-arith: