Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
43ebce2
fix: preserve calc() around floats by default
ludofischer Sep 12, 2026
3940996
test: reorganize test files so they are not huge and named consistently
ludofischer Sep 12, 2026
2c1d81e
refactor: centralize serialization
ludofischer Sep 13, 2026
e61040e
refactor: centralize calculation analysis and function registry
ludofischer Sep 14, 2026
5a4599f
refactor: model opaque values explicitly in the AST
ludofischer Sep 14, 2026
73cd83b
refactor: make calculation reduction stages explicit
ludofischer Sep 14, 2026
0488fc9
test: cover spec-conformant calculation behavior
ludofischer Sep 14, 2026
55c8979
chore: remove redundant tokenizer wrapper
ludofischer Sep 14, 2026
a1833d8
docs: document spec-conformance behavior
ludofischer Sep 14, 2026
a356114
fix: lowercase min and max function names
ludofischer Sep 14, 2026
ba281b5
refactor: improve serializer readability
ludofischer Sep 14, 2026
a4278d9
refactor: refactor parser
ludofischer Sep 14, 2026
670bbac
chore: improve benchmark harness
ludofischer Sep 14, 2026
9d654df
refactor: remove extra custom error class
ludofischer Sep 16, 2026
8b324ea
refactor: remove test-only indexBlocks fallback
ludofischer Sep 16, 2026
5556ae6
refactor: improve parser typing
ludofischer Sep 16, 2026
c8e92dc
chore: prevent benchmark from crashing
ludofischer Sep 16, 2026
82945ed
perf: speed up hot spots
ludofischer Sep 16, 2026
c0b2143
refactor: do not re-export functions
ludofischer Sep 16, 2026
7f0ba65
refactor: remove superfluous custom error class
ludofischer Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.


## Unreleased

### Bug Fixes

- Resolved calculation roots now default to standard serialization inside
`calc()`. Use `unwrapSingleValue: true` to emit fully resolved finite scalar
results as bare values, since unwrapping can discard browser-applied range clamping or integer
rounding.

### Migration

The published `unwrapSingleNegativeNumber` option remains available as a
deprecated alias for `unwrapSingleValue`.

## 11.1.2 (2026-09-11)

### Bug fixes
Expand Down
55 changes: 38 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ you will get:

```css
h1 {
font-size: 32px;
font-size: calc(32px);
height: calc(100px - 2em);
width: calc(2 * var(--base-width));
margin-bottom: 24px;
margin-bottom: calc(24px);
}
```

Expand All @@ -62,14 +62,15 @@ leaving all other text untouched.
import reduceCalc from 'postcss-calc/reduce';

reduceCalc('calc(1in + 10px)');
// => '1.10417in'
// => 'calc(1.10417in)'

reduceCalc('min(50px, calc(2 * 40px))');
// => '50px'
// => 'calc(50px)'
```

It accepts `precision`, `unwrapSingleNegativeNumber`, `warnWhenCannotResolve`, `onParseError`,
and `onWarn`:
It accepts `precision`, `unwrapSingleValue`, the deprecated
`unwrapSingleNegativeNumber` alias,
`warnWhenCannotResolve`, `onParseError`, and `onWarn`:

```js
const result = reduceCalc('calc(100% + var(--gap))', {
Expand All @@ -87,21 +88,27 @@ by default; provide `onParseError` and/or `onWarn` if you want diagnostics.

### Standalone reducer options

#### `unwrapSingleNegativeNumber` (default: `false`)
#### `unwrapSingleValue` (default: `false`)

Controls whether a finite negative result is serialized as a bare value or
wrapped in `calc()`. Keep the default when reducing declaration values; set it
to `true` when the surrounding CSS context requires a bare negative value, such
as a selector:
Serializes a fully resolved finite scalar result without calculation syntax.
Keep the default for standard CSS so the browser can perform range clamping
and integer rounding. Set it to `true` for a non-standard context that requires
a bare value, such as a selector:

```js
reduceCalc('calc(5px - 10px)');
// => 'calc(-5px)'

reduceCalc('calc(5px - 10px)', { unwrapNegativeNumbers: true });
reduceCalc('calc(5px - 10px)', { unwrapSingleValue: true });
// => '-5px'

reduceCalc('calc(1 / 2)', { unwrapSingleValue: true });
// => '.5'
```

The published `unwrapSingleNegativeNumber` option is retained as a deprecated
alias for `unwrapSingleValue`.

### PostCSS plugin options

These options apply when using the PostCSS plugin:
Expand All @@ -121,6 +128,12 @@ var out = postcss()
.process(css).css;
```

#### `unwrapSingleValue` (default: `false`)

Serializes fully resolved finite scalar results without calculation syntax.
This can discard browser-applied range clamping or integer rounding. Selectors
enable it automatically because selectors cannot contain `calc()`.

#### `warnWhenCannotResolve` (default: `false`)

Adds warnings when calc() are not reduced to a single value.
Expand Down Expand Up @@ -165,9 +178,9 @@ With `mediaQueries: true`, this becomes:

Reduces `calc()` functions found in selectors. Selectors do not accept
`calc()` functions, so the plugin replaces them with their reduced values.
Finite negative results are serialized as bare values because a selector cannot
contain a `calc()` function; the plugin enables `unwrapSingleNegativeNumber` automatically
for selectors.
Finite negative and fractional unitless results are serialized as bare values
because a selector cannot contain a `calc()` function; the plugin enables the
`unwrapSingleValue` automatically for selectors.

```js
var out = postcss()
Expand Down Expand Up @@ -268,7 +281,15 @@ when changing parsing/simplification behavior:
pnpm test:corpus:full
```

Profile long arithmetic parser chains with `pnpm benchmark:arithmetic-chains`.
Profile parser chains with `pnpm benchmark:arithmetic-chains` or
`pnpm benchmark:nested-fallbacks`; both use 20 fresh paired blocks by default
and write ignored schema-v2 reports. Compare a saved report with
`node scripts/compare-parser-benchmarks.js <report>`. Run the correctness-aware
corpus benchmark with `pnpm benchmark:corpus`.

The PostCSS benchmark awaits `postcss().process(...)`, and that await already
triggers result stringification. It therefore does not add a redundant
`result.css` access.

## [Changelog](CHANGELOG.md)

Expand All @@ -281,5 +302,5 @@ Profile long arithmetic parser chains with `pnpm benchmark:arithmetic-chains`.
[PostCSS]: https://github.com/postcss
[PostCSS Calc]: https://github.com/postcss/postcss-calc
[PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties
[tests]: test/index.js
[tests]: test/
[W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation
16 changes: 10 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@
"scripts": {
"lint": "oxlint . && tsc && oxfmt --check",
"fmt": "oxfmt",
"benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.mjs",
"benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.mjs",
"test": "node --test --test-reporter=dot 'test/**/*.test.mjs' test/index.cjs test/convertUnit.cjs",
"test:mutation:corpus": "node test/mutation/corpus-selection.mjs",
"test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.mjs"
"benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.js",
"benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.js",
"benchmark:corpus": "node scripts/benchmark.js",
"benchmark:serialization": "node scripts/benchmark-serialization.js",
"test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js",
"test:benchmark:simulation": "node test/benchmark/statistical-simulation.js",
"benchmark:reanalyze": "node scripts/compare-parser-benchmarks.js",
"test": "node --test --test-reporter=dot 'test/**/*.test.js' 'test/**/*.test.cjs'",
"test:mutation:corpus": "node test/mutation/corpus-selection.js",
"test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.js"
},
"author": "Andy Jansson",
"license": "MIT",
Expand All @@ -52,7 +57,6 @@
},
"devDependencies": {
"@csstools/css-calc": "^3.3.0",
"@rmenke/css-tokenizer-tests": "^1.2.0",
"@types/node": "^26.5.1",
"fast-check": "^4.10.0",
"oxfmt": "^0.68.0",
Expand Down
24 changes: 8 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

142 changes: 126 additions & 16 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,133 @@
# scripts/
# Benchmark scripts

None of these run in `pnpm test` or CI — run directly with `node scripts/<name>.mjs`.
These scripts are deliberately outside the ordinary test suite. Run them on a
controlled machine with `node scripts/<name>.js` (or the corresponding pnpm
command). Benchmark artifacts are schema-v2 JSON files and retain raw
observations, configuration, provenance, and enough information for offline
reanalysis.

- **`benchmark-arithmetic-chains.mjs`** — times parser construction and flattening across long arithmetic chains.
- **`benchmark-nested-fallbacks.mjs`** — measures parser scaling for nested var() fallbacks with increasing depths.
- **`harvest-github.mjs`** — scrapes real-world `calc()` expressions from
- **`benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser
benchmark for arithmetic shapes. `benchmark-nested-fallbacks.js` does the
same for nested `var()` fallbacks. Both accept `--baseline`, `--blocks`,
`--max-attempts`, `--seed`, and `--output`, and write schema-v2 artifacts under
`reports/benchmarks/`.
- Parser benchmark exit codes are `0` pass, `1` regression, `2` inconclusive,
`3` benchmark/correctness/infrastructure failure, and `64` invalid usage or
artifact. Twenty blocks are the minimum operational floor, not a guarantee
of adequate precision or power. The artifact reports observed variance,
interval width, and estimated blocks needed for the declared margin. A pass
requires every gated runtime, slope, and growth endpoint to meet its
predeclared precision target; the requested block count is never increased
from an observed effect during a run.
- **`compare-parser-benchmarks.js`** — reanalyzes one schema-v2 parser
artifact and applies the uncertainty-aware runtime, slope, and growth gates.
- **`benchmark-serialization.js`** — measures buffered serializer scaling for wide sums/products, nested calls, and nested opaque fallbacks.
- **`harvest-github.js`** — scrapes real-world `calc()` expressions from
public GitHub into `test/corpus/github/expressions.txt`.
- **`split-corpus.mjs`** — splits that file into `github-pure.txt` (feeds
`benchmark.mjs`/`show-divergences.mjs` below), `preprocessor.txt`, and
- **`split-corpus.js`** — splits that file into `github-pure.txt` (feeds
`benchmark.js`/`show-divergences.js` below), `preprocessor.txt`, and
`invalid.txt` (the latter two are used by real CI resilience tests).
- **`lib/corpus.mjs`** — shared loader for `github-pure.txt`.
- **`benchmark.mjs`** — times our pipeline against `@csstools/css-calc` over
the pure corpus.
- **`show-divergences.mjs`** — buckets where our output disagrees with
- **`lib/corpus.js`** — shared loader for `github-pure.txt`.
- **`benchmark.js`** — (`pnpm benchmark:corpus`) validates and times our
pipeline against `@csstools/css-calc` over the pure corpus in fresh
processes. It is report-only for speed; correctness and infrastructure
failures are nonzero.
- **`benchmark-plugin.js`** — measures PostCSS processing; awaiting
`.process(...)` already includes result serialization, so the benchmark does
not add a redundant `result.css` read.
- **`show-divergences.js`** — buckets where our output disagrees with
`@csstools/css-calc` over the pure corpus, for manual triage.
- **`tokenizer-compat.mjs`** — shared helpers for diffing token streams
(not runnable on its own); used by `tokenizer-suite.mjs`.
- **`tokenizer-suite.mjs`** — runs the official `@rmenke/css-tokenizer-tests`
corpus through our tokenizer and reports pass/fail per category.
- **`randomizer.mjs`** — long-running fuzzer: generates `calc()` inputs at
- **`randomizer.js`** — long-running fuzzer: generates `calc()` inputs at
increasing depth, compares against `@csstools/css-calc`, logs finds to
`reports/randomizer-finds.jsonl`.

## Claims and decision protocol

| Benchmark | Claim type | Estimand / unit | Decision |
| ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Parser workloads | regression-gating | equal-weighted, order-stratified fresh-process blocks for each generated endpoint | stratified studentized max-T interval; a regression or pass requires the simultaneous bound and family-adjusted precision target; otherwise inconclusive |
| Corpus reducer | comparative-reporting | relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations | paired fresh-process replicates retain both order measurements; 95% superiority and separate 90% practical-margin intervals |
| Serializer script | local profiling | repeated alternating calls to worktree and `HEAD` serializers in one process; outputs are compared before timing | prints two medians per workload; no raw artifact or uncertainty interval |
| Adapter script | local profiling | repeated PostCSS processing by the current implementation in one process | prints one median per workload; no baseline comparison, raw artifact, uncertainty interval, or correctness gate |

Parser artifacts use an equal-weighted two-stratum estimator over
`baseline-first` and `candidate-first` blocks. Their intervals are a
stratified, studentized max-T bootstrap: each complete block is the
independent experimental unit, resampling is independent within process-order
strata, and the family critical value is transformed with each endpoint's
observed standard error. Parser regressions require both a simultaneous bound
beyond the runtime threshold and a family-adjusted interval no wider than the
configured precision target; insufficient precision yields `inconclusive`.
When a bootstrap resample has zero standard error but a nonzero deviation, its
studentized statistic uses that endpoint's observed standard error. The
artifact records how many resamples used this fallback.

The corpus benchmark runs each replicate in a fresh process. Each replicate
contains three observations in each execution order, and the bootstrap samples
complete replicate records so the two order measurements stay paired. The
corpus is unique-weighted, not frequency-weighted, and its results must not be
generalized to all real-world CSS. The serializer and adapter scripts are
profiling aids; their medians are not regression gates.

Corpus correctness is checked against the public `reduceCalc()` API used by
the timer. An untimed pass canonicalizes each public output and its accepted
canonical result to a common form, then records a checksum over the exact
public output content.

The corpus practical-equivalence margin is named in the artifact as
`equivalenceMargin: 1.1` (10%). A result is within the declared margin only
when its 90% interval lies wholly inside `[1 / 1.1, 1.1]`; this is not a claim
that the implementations are identical; the artifact labels this practical
field `equivalent` and retains the declared margin. The statistical verdict is separate:
`postcss-calc faster` requires a 95% upper bound below 1,
`postcss-calc slower` requires a 95% lower bound above 1, and every other case
is `inconclusive`.

`precisionMargin` is recorded separately from the runtime and growth decision
thresholds. Precision is met only when the actual family-adjusted interval's
half-width is within the configured log-scale target and the minimum block
count is present. The decision configuration records this as
`precisionMethod: family-adjusted-interval-width`; the normal approximation
parameter used by the earlier protocol is no longer part of new artifacts.
`requestedBlocks` records the sample count requested before observations were
collected.

Drift rejection and structural mismatch are separate. Structural mismatch is
an immediate correctness failure. Drift-rejected attempts remain in the
artifact; the primary analysis uses the predeclared accepted-block policy and
the sensitivity analysis uses all structurally valid attempts with the same
order-adjusted estimator. Both summaries are recorded, and disagreement makes
the result inconclusive. The drift threshold and order-interaction threshold
are named and recorded in the artifact rather than inferred during reanalysis.
Reanalysis executes no workload: raw observations plus the recorded decision
configuration are the sole source of truth.

## Controlled-run checklist

Before a long run, use an idle machine on AC power, a stable CPU governor, no
concurrent builds, and the same Node version for baseline and candidate. Record
warnings if the governor, load, or dirty worktree is unsuitable. Repeat the run
when control drift or rejection rates are high; the metadata records these
conditions but cannot fully control them.

Useful verification commands:

```sh
pnpm test:benchmark
pnpm test:benchmark:simulation
pnpm benchmark:reanalyze reports/benchmarks/<artifact>.json
```

The normal test command should keep schema checks, analyzer tests, simulation
smoke tests, and synthetic slowdown fixtures short. The fixed-seed smoke
calibration runs 200 experiments; setting
`POSTCSS_CALC_FULL_CALIBRATION=1` runs the thousands-of-experiments,
production-like calibration outside normal CI. Full corpus and long benchmark
runs remain explicit operations.

The fixed-seed simulation smoke test expects simultaneous 95% coverage between
0.90 and 0.99 over 200 experiments. This binomial tolerance is an operational
check, not a proof of coverage for every workload: endpoint correlation, skew,
temporal drift, order penalties, and outliers can differ in production. The
full calibration uses a tighter 0.925–0.975 range over 2,000 experiments and
should be rerun when changing the interval procedure.
Loading
Loading