quario 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **Styled runs.** Bold a word, colour a phrase, or format one value inside a
15
+ sentence, and have it survive into the PDF and the spreadsheet — not only the
16
+ HTML, which was the only place `<b>` in a template ever meant anything. A run
17
+ carries the inline declarations only: no padding, borders or spacing, which
18
+ belong to a whole line or block.
19
+
20
+ A cell's tokens carry a run's resolved style on the tokens it covers, and
21
+ `styledRuns(tokens)` groups them back out — the same rule every built-in
22
+ target reads them through, so a custom target cannot drift from them.
23
+ `RUN_STYLE_NAMES` is the inline half of the vocabulary as a list, beside
24
+ `STYLE_NAMES`, for a tool that offers an author what a run may wear.
25
+
26
+ - **`plan()` now reports declarations that nothing will read.** A definition can
27
+ be perfectly valid and still say something the engine quietly drops, and
28
+ until now the only way to find out was to notice the output was wrong.
29
+ `plan()` returns a `warnings` list beside `problems` — same shape, same
30
+ document order, one entry per declaration — and it currently catches three:
31
+
32
+ - a `format` on a cell whose value can never present a single interpolation —
33
+ `"Due {{ due }}"` under `format: "date"` — which the engine leaves unread;
34
+ - a `currency` on a cell whose `format` is not `"currency"`, which the engine
35
+ reads only under that kind and otherwise ignores, so the cell renders in
36
+ the instance's currency and the code you wrote does nothing;
37
+ - a table where every column declares a `width` and the widths total under
38
+ 100, which leaves the trailing share of the table unused — usually a column
39
+ edited down without its neighbours being adjusted.
40
+
41
+ `problems` is unchanged and still fatal: a definition with problems compiles
42
+ to nothing, while one carrying only warnings compiles and renders exactly as
43
+ before. Hosts that ignore the new field are unaffected, and `validate()` is
44
+ untouched.
45
+
46
+ Warnings are advisory and deliberately incomplete — they catch what can be
47
+ seen in the definition alone, without data, so a quiet `warnings` list is not
48
+ a promise that every declaration will be read.
49
+
50
+ ### Changed
51
+
52
+ - **A `format` declaration now needs one value to speak about, and a cell can
53
+ give it one.** Previously a `format` on a cell reached every interpolation in
54
+ it and skipped the literal text between them, which meant
55
+ `"{{ amount }} of {{ quantity }}"` under `format: "currency"` rendered the
56
+ quantity as money too — and the page and the spreadsheet disagreed about it,
57
+ the page formatting inside a sentence where the sheet did not.
58
+
59
+ A cell `value` may now be a list of **styled runs** —
60
+ `{ "value": "…", "style": { … } }` — each with its own declarations, and
61
+ `format` applies to a run holding a single interpolation.
62
+
63
+ **What changes in an existing report:** a cell that mixes text and an
64
+ interpolation under a `format` stops presenting that value and renders it
65
+ plainly, in every target. `"Due {{ due }}"` with `format: "date"` was
66
+ `Due 14 Aug 2026` on the page and `Due 2026-08-14` in the sheet; it is now
67
+ the plain form in both. To restore it, split the value and leave the
68
+ declaration where it is:
69
+
70
+ ```jsonc
71
+ {
72
+ "value": [{ "value": "Due " }, { "value": "{{ due }}" }],
73
+ "style": { "format": "date" }
74
+ }
75
+ ```
76
+
77
+ A cell's `format` reaches its runs, so nothing moves and nothing is declared
78
+ twice. `plan()` reports the cells this affects wherever it can see them
79
+ without data.
80
+
81
+ ### Fixed
82
+
83
+ - **`round`, `floor` and `ceil` reuse their formatters instead of rebuilding
84
+ one per call.** The three reach the decimal you wrote through `Intl`, and
85
+ each call built a fresh formatter to do it — so a detail column of
86
+ `{{ round(@.qty * @.price, 2) }}` paid for one per row. A hundred-thousand
87
+ row report with two such columns drained in 3.5 s; it now drains in 0.2 s.
88
+ Every answer is the value it was before: the rounding mode and the digit
89
+ count are both part of what a reused formatter is found by, so no call can
90
+ be handed one built for another.
91
+
92
+ - **`round`, `floor` and `ceil` name themselves when they refuse an `n`.** All
93
+ three threw `n must be a number from 0 to 100`, so a cell calling more than
94
+ one was told how it failed and never which call; the message now opens with
95
+ the function, as a reducer's already did. The located path still names the
96
+ cell — one cell holds as many calls as you write, which is the whole reason
97
+ the name is worth having.
98
+
99
+ **An `n` that cannot be read as a number at all now gets the same answer.**
100
+ A `BigInt` or a `Symbol` reaching `n` from render data used to surface the
101
+ runtime's own `Cannot convert a BigInt value to a number`, and an object
102
+ carrying a throwing `valueOf` surfaced whatever it threw — each located at
103
+ the cell but naming neither the function nor anything an author could act
104
+ on. All of them are now the same refusal.
105
+
106
+ - **`isDiagnostic` is documented for what it actually answers.** The README
107
+ said it was true for "one of the stack's located errors: quario's own, or one
108
+ thrown directly by xprsn, sjabloon, or padvinder", and its example read a
109
+ `false` as "one of your own functions failed". Only an engine's own error is
110
+ a diagnostic: quario's verdicts on a document — an unknown reducer, a
111
+ misused inline reducer, image bytes it will not vouch for — are located just
112
+ the same and are not diagnostics, so a host following that example rethrew
113
+ report faults as its own. Nothing about the guard changes; it never behaved
114
+ the way the page described.
115
+
116
+ - **A misused inline reducer says what it is, instead of how it folds.**
117
+ `{{ min(1, 2) }}` — the two-argument scalar `min` quario does not have —
118
+ failed at the cell with `(rows || []).map is not a function`, the fold's own
119
+ internals, naming neither the reducer nor the mistake. It now reads
120
+ `min is a reducer over an array, not a scalar function; got a number`, and a
121
+ second argument that is not a lambda says so in the same words rather than
122
+ reaching a host as `of is not a function`. Register a function of your own to
123
+ shadow a built-in reducer when a report needs the scalar.
124
+
125
+ **Two reducers used to answer instead of failing**, each reading a `length`
126
+ off a value that is not a collection: `count('ab')` presented `2` — a
127
+ string's own length, folded as if it were a count of rows — while `count(1)`
128
+ and `avg(1, 2)` presented nothing at all. All three are now the same located
129
+ error. A reducer over an **absent** array is unchanged and still not a
130
+ mistake: `sum(@.lines)` on a row carrying no lines is `0`, exactly as an
131
+ empty array gives.
132
+
10
133
  ## [0.6.0] - 2026-09-07
11
134
 
12
135
  ### Added
package/README.md CHANGED
@@ -127,6 +127,14 @@ display text. Passing the cell's resolved `format` kind opts into the seam's one
127
127
  real numeric cells;
128
128
  [`@quario/csv`](https://www.npmjs.com/package/@quario/csv) is the short form.
129
129
 
130
+ ### `styledRuns(tokens)`
131
+
132
+ Groups a cell's tokens into its **styled runs**, in order — each `{ style, tokens }`,
133
+ with `style` `null` where the tokens carry none and the cell's own applies. Consecutive tokens
134
+ with equal styles are one run, which is lossless: equal styles render identically, so the grouping
135
+ survives a JSON round trip. Every built-in target reads a cell's runs through this, so a custom
136
+ target cannot drift from them.
137
+
130
138
  ### `walk(events, handlers)` / `breathe()`
131
139
 
132
140
  `walk` is the delivery driver every official target uses. Pass one render's event iterable and
@@ -154,7 +162,8 @@ cell the same way:
154
162
  - `currencyOf(style?, options?)` — which code a money cell wears: its own, else the instance's.
155
163
  - `isReportBand(role)` — whether a role names one of the report's own bands rather than a
156
164
  group's.
157
- - `STYLE_NAMES` — the closed style vocabulary, in the spec's order.
165
+ - `STYLE_NAMES` — the closed style vocabulary, in the spec's order, and
166
+ `RUN_STYLE_NAMES` — the inline half of it, which is what a styled run may wear.
158
167
 
159
168
  ### `validate(schema, functions?)`
160
169
 
@@ -172,34 +181,49 @@ so `validate()` can never disagree with what `report()` accepts.
172
181
  ### `quario().plan(schema, functions?)`
173
182
 
174
183
  The one traversal, whole — for hosts that validate and render in a loop, like an editor. Returns
175
- `{ report, problems, anchors }`: the compiled report (`null` while the document has problems),
176
- every problem structurally as `{ path, source?, message, diagnostic? }` (the `message` is exactly
177
- `validate()`'s string, and every problem keeps its own located diagnostic with `start`/`end`
178
- offsets, not only the first), and `anchors`, mapping each compiled source's schema path to the
179
- anchors and group handles it reads — the unfiltered complement of `names`.
184
+ `{ report, problems, anchors, warnings }`: the compiled report (`null` while the document has
185
+ problems), every problem structurally as `{ path, source?, message, diagnostic? }` (the `message`
186
+ is exactly `validate()`'s string, and every problem keeps its own located diagnostic with
187
+ `start`/`end` offsets, not only the first), `anchors`, mapping each compiled source's schema path
188
+ to the anchors and group handles it reads — the unfiltered complement of `names` — and
189
+ `warnings`.
190
+
191
+ A warning is `{ path, source?, message }`: the document declares something nothing will read. It
192
+ is not a problem at a lower severity, which is why it has no `diagnostic` — nothing raised, the
193
+ engine decided. Neither warning below locates into an authored source, so none carries a `source`
194
+ today. **A warning is never fatal**: a document carrying only warnings compiles and
195
+ renders, so `report` is `null` on `problems` alone. Two declarations warn today — a `currency` on
196
+ a cell whose `format` is not `"currency"`, and a table where every column is sized and the widths
197
+ total under 100 — and the list is advisory and deliberately incomplete, so a quiet one is not a
198
+ promise that every declaration will be read. `validate()` returns problems only.
180
199
 
181
200
  ```js
182
- const { report, problems, anchors } = quario().plan(schema);
201
+ const { report, problems, anchors, warnings } = quario().plan(schema);
202
+ for (const warning of warnings) console.warn(warning.message);
183
203
  if (report) await report.render(html(), data);
184
204
  else console.error(problems[0].path, problems[0].message);
185
205
  ```
186
206
 
187
207
  ### `isDiagnostic(error)`
188
208
 
189
- True when a caught value is one of the stack's located errors: quario's own, or one thrown
190
- directly by xprsn, sjabloon, or padvinder. Authentication checks identity. An error that only
191
- matches the shape does not pass.
209
+ True when a caught value is a **located diagnostic**: an error xprsn, sjabloon or padvinder
210
+ minted — thrown by that engine, or re-thrown by quario with the engine original behind it.
211
+ Authentication checks identity. An error that only matches the shape does not pass.
192
212
 
193
- Located errors name their band path and offending source while keeping their original type
194
- (`SyntaxError`, `TypeError`, `RangeError`). They carry `code`, `start`/`end` offsets, and (for
195
- query budget failures) `limit` and `actual`.
213
+ Being located is not what the guard reads: quario's own verdicts on a document and a registered
214
+ function's own throw are located too, and neither is a diagnostic. Errors a report throws
215
+ name the path they failed at — and the offending source, where there is one — while keeping their
216
+ original type (`SyntaxError`, `TypeError`, `RangeError`). What a diagnostic adds on top is
217
+ metadata an engine vouches for: `code`, `start`/`end` offsets, and, for a query budget in place
218
+ of those offsets, `limit` and `actual`.
196
219
 
197
220
  ```js
198
221
  try {
199
222
  await report.render(html(), data);
200
223
  } catch (e) {
201
- if (isDiagnostic(e)) console.error("report problem:", e.message);
202
- else throw e; // one of your own functions failed
224
+ // Every error names where it failed; a diagnostic also carries an engine's own metadata.
225
+ if (isDiagnostic(e)) console.error(e.code, e.start, e.end);
226
+ throw e;
203
227
  }
204
228
  ```
205
229
 
package/lib/format.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * carries no count presents nothing here and the caller falls back to
18
18
  * `display()`.
19
19
  */
20
+ import { boundedMemo } from "./memo.js";
20
21
  import { currencyOf, formatOf } from "./style.js";
21
22
  import { finiteDate, finiteNum, reviveDate } from "./stream.js";
22
23
  /** @type {(options: any) => string} */
@@ -24,11 +25,9 @@ let localeOf = (options) => options?.locale || "en-US";
24
25
  /** @type {(options: any) => string} */
25
26
  let zoneOf = (options) => options?.timeZone || "UTC";
26
27
 
27
- // Building an `Intl` formatter costs about 22 µs; using one costs 0.3 µs. A
28
- // report presents cell after cell from a handful of distinct formats, so
29
- // constructing per token spent 3.2 s on a hundred thousand presented cells
30
- // where 0.2 s does (a report of mixed currencies rendered to HTML), and the
31
- // formatters are memoised.
28
+ // The formatters are memoised behind `./memo.js`'s bounded store, which
29
+ // carries the rules a bounded memo has; what is here is what only this call
30
+ // site knows -- its key, and its cap.
32
31
  //
33
32
  // **The key is everything the formatter is made from**, and it is values
34
33
  // rather than an object identity because no identity survives both paths: a
@@ -48,12 +47,6 @@ let zoneOf = (options) => options?.timeZone || "UTC";
48
47
  // single locale. Measured at 244 bytes retained per entry, that is about 90 MB
49
48
  // held for the life of the process on data nobody vetted.
50
49
  //
51
- // So the map is bounded, and past the bound it is emptied rather than grown.
52
- // A workload presenting more distinct formats than the cap pays what it paid
53
- // before any of this and never more, which is the one cost that needs no
54
- // argument -- and it can never hand back a formatter built for another cell,
55
- // because clearing forgets rather than reuses.
56
- //
57
50
  // 2048 leaves the margin a threshold like this wants on both sides: about
58
51
  // 500 KB at the cap, against a real report reaching a small multiple of the
59
52
  // ISO codes actually in circulation, which is under two hundred. Raising it
@@ -62,23 +55,7 @@ let zoneOf = (options) => options?.timeZone || "UTC";
62
55
  //
63
56
  // Memoising a fact that cannot change within an ICU version, like
64
57
  // `fractionDigits`' own minor-units map (docs/adr/0025).
65
- let CAP = 2048;
66
- /** @type {Map<string, any>} */
67
- let formatters = new Map();
68
- // A construction that throws -- an unreadable locale, zone, or currency code
69
- // -- caches nothing: it reaches `format()`'s own catch, and a later cell asks
70
- // again rather than finding nothing sitting in the map under a good key.
71
- // Missing on `undefined` rather than `has()`, which is what `precision.js`
72
- // needs because *it* caches an absent answer; a formatter is never one.
73
- /** @type {(key: string, make: () => any) => any} */
74
- let memo = (key, make) => {
75
- let formatter = formatters.get(key);
76
- if (formatter !== undefined) return formatter;
77
- formatter = make();
78
- if (formatters.size >= CAP) formatters.clear();
79
- formatters.set(key, formatter);
80
- return formatter;
81
- };
58
+ let memo = boundedMemo(2048);
82
59
 
83
60
  // The kind leads, so a number's key can never read as a date's; the currency
84
61
  // is empty for the two kinds that wear none.
package/lib/index.d.ts CHANGED
@@ -118,8 +118,52 @@ export interface SortKey {
118
118
  dir?: "asc" | "desc";
119
119
  }
120
120
 
121
- /** A cell value: one template string. */
122
- export type CellValue = string;
121
+ /**
122
+ * The names a styled run may wear, in the vocabulary's own order. A tool that
123
+ * offers them — the editor's style rail — reads this rather than keeping a
124
+ * copy, exactly as it reads `STYLE_NAMES` for the whole set.
125
+ */
126
+ export const RUN_STYLE_NAMES: readonly string[];
127
+
128
+ /**
129
+ * The declarations a styled run accepts: the inline half of the vocabulary,
130
+ * and nothing else. The box and flow spacing describe a block and `align`
131
+ * describes a line, and neither means anything on part of one — so a run is
132
+ * narrowed from the other end than an image item is. A narrowing of the one
133
+ * vocabulary rather than a second list of its own, so the two cannot drift.
134
+ */
135
+ export type RunStyleDeclarations = Pick<
136
+ StyleDeclarations,
137
+ | "family"
138
+ | "size"
139
+ | "bold"
140
+ | "italic"
141
+ | "underline"
142
+ | "strikethrough"
143
+ | "color"
144
+ | "background"
145
+ | "uppercase"
146
+ | "format"
147
+ | "currency"
148
+ >;
149
+
150
+ /**
151
+ * One styled run: a stretch of a cell's text with its own declarations. A
152
+ * closed two-key object — `{{#if}}` already conditions a run's own text, so
153
+ * there is no `visible` on one.
154
+ */
155
+ export interface StyledRun {
156
+ value: string;
157
+ /** Literal only — never an expression. */
158
+ style?: RunStyleDeclarations;
159
+ }
160
+
161
+ /**
162
+ * A cell value: one template string, or a non-empty array of styled runs. A
163
+ * cell written as one template string is one run, so a one-run array says
164
+ * exactly what that string says.
165
+ */
166
+ export type CellValue = string | StyledRun[];
123
167
 
124
168
  export type Align = "left" | "center" | "right";
125
169
  export type VAlign = "top" | "middle" | "bottom";
@@ -194,8 +238,12 @@ export interface ImageItem {
194
238
  source: `=${string}`;
195
239
  /** Defaults to `"natural"`. */
196
240
  fit?: ImageFit;
197
- /** The image's textual stand-in, as a cell value. */
198
- alt?: CellValue;
241
+ /**
242
+ * The image's textual stand-in: one template string. A styled run means
243
+ * nothing on a stand-in for a picture, so this is the one cell value the
244
+ * widened type does not reach.
245
+ */
246
+ alt?: string;
199
247
  visible?: ExpressionValue<boolean>;
200
248
  style?: ImageStyleDeclarations;
201
249
  }
@@ -445,14 +493,43 @@ export type ItemRole =
445
493
  | "page-header"
446
494
  | "page-footer";
447
495
 
496
+ /**
497
+ * The resolved declarations for one stretch of a cell's text: the cell's own
498
+ * with its styled run's layered over them, already composed, so a consumer
499
+ * applies it exactly where it applies the cell's `style` rather than merging
500
+ * the two. Present only where it differs from the cell's own.
501
+ */
502
+ export interface TokenStyle {
503
+ style?: Record<string, unknown>;
504
+ }
505
+
448
506
  /** One static text run of a cell's template, verbatim (sjabloon's literal token). */
449
- export type LiteralToken = SjabloonLiteralToken;
507
+ export type LiteralToken = SjabloonLiteralToken & TokenStyle;
450
508
 
451
509
  /** One interpolation's pre-stringify value (sjabloon's value token). */
452
- export type ValueToken = SjabloonValueToken;
510
+ export type ValueToken = SjabloonValueToken & TokenStyle;
453
511
 
454
512
  export type Token = LiteralToken | ValueToken;
455
513
 
514
+ /** One of a cell's styled runs, as `styledRuns` groups them back out. */
515
+ export interface TokenRun {
516
+ /** The style this run's tokens carry, or `null` where the cell's applies. */
517
+ style: Record<string, unknown> | null;
518
+ tokens: Token[];
519
+ }
520
+
521
+ /**
522
+ * Group a cell's tokens into its styled runs, in order. Consecutive tokens
523
+ * with equal styles are one run — a lossless rule, because equal styles render
524
+ * identically, so grouping survives a JSON round trip. Equality is compared
525
+ * one level deep, which is exhaustive rather than approximate: `format` is the
526
+ * only object-valued declaration and is itself flat.
527
+ *
528
+ * The built-in targets consume it, so a custom target grouping through it
529
+ * cannot drift from them.
530
+ */
531
+ export function styledRuns(tokens: Token[]): TokenRun[];
532
+
456
533
  export interface EventCell {
457
534
  tokens: Token[];
458
535
  style?: Record<string, unknown>;
@@ -744,17 +821,35 @@ export interface Problem {
744
821
  readonly diagnostic?: QuarioDiagnostic;
745
822
  }
746
823
 
824
+ /**
825
+ * An advisory: the document declares something nothing will read. Not a
826
+ * `Problem` at lower severity, and not its shape either — a problem carries
827
+ * the engine's located `diagnostic` when one authenticated the fault, and a
828
+ * `source` when it came from compiling one. Neither can happen here: nothing
829
+ * raises, and the engine decides a declaration will not be read after every
830
+ * compile has already succeeded. A warning that did locate into an authored
831
+ * source would add the field back; carrying one nothing sets would promise a
832
+ * location that never arrives.
833
+ */
834
+ export interface Warning {
835
+ readonly path: string;
836
+ readonly message: string;
837
+ }
838
+
747
839
  /**
748
840
  * The plan: both readings of the one traversal, kept. `report` is the
749
841
  * compiled report, or null while the document has problems; `problems` is the
750
- * structured list `validate()` flattens to strings; `anchors` maps each
751
- * compiled source's schema path to the anchors and group handles it reads —
752
- * the unfiltered complement of `names`, which excludes them.
842
+ * structured list `validate()` flattens to strings; `warnings` is the advisory
843
+ * half, which never nulls the report; `anchors` maps each compiled source's
844
+ * schema path to the anchors and group handles it reads — the unfiltered
845
+ * complement of `names`, which excludes them. `problems` is fatal and
846
+ * `warnings` is advisory, both in document order from the one descent.
753
847
  */
754
848
  export interface Plan {
755
849
  readonly report: CompiledReport | null;
756
850
  readonly problems: readonly Problem[];
757
851
  readonly anchors: Readonly<Record<string, readonly string[]>>;
852
+ readonly warnings: readonly Warning[];
758
853
  }
759
854
 
760
855
  export interface Quario {
@@ -767,8 +862,8 @@ export interface Quario {
767
862
  report(schema: ReportSchema, functions?: FunctionRegistry): CompiledReport;
768
863
  /**
769
864
  * The one traversal, whole: compiled report (null on problems), structured
770
- * problems, and per-node anchor sets — one descent per edit for a host that
771
- * validates and renders in a loop.
865
+ * problems, advisory warnings, and per-node anchor sets — one descent per
866
+ * edit for a host that validates and renders in a loop.
772
867
  */
773
868
  plan(schema: unknown, functions?: FunctionRegistry): Plan;
774
869
  }
package/lib/index.js CHANGED
@@ -24,9 +24,15 @@ import { opt } from "./stream.js";
24
24
  // The event stream's public seam, single-sourced in ./stream.js, and the
25
25
  // diagnostic predicate in ./locate.js. Re-exported here because a consumer
26
26
  // imports them from the package, not from a file inside it.
27
- export { breathe, display, isReportBand, text, typed, walk } from "./stream.js";
27
+ export { breathe, display, isReportBand, styledRuns, text, typed, walk } from "./stream.js";
28
28
  export { format } from "./format.js";
29
- export { currencyOf, FORMAT_VOCABULARY, fractionDigits, STYLE_NAMES } from "./style.js";
29
+ export {
30
+ currencyOf,
31
+ FORMAT_VOCABULARY,
32
+ fractionDigits,
33
+ RUN_STYLE_NAMES,
34
+ STYLE_NAMES,
35
+ } from "./style.js";
30
36
  export { imageError, isDiagnostic } from "./locate.js";
31
37
 
32
38
  /** @typedef {import("./scope.js").Scope} Scope */
@@ -89,11 +95,13 @@ export function validate(schema, funcs) {
89
95
  return plan(schema, funcs).problems.map((problem) => problem.message);
90
96
  }
91
97
 
92
- // The structured problems, frozen for handing out: the traversal's own objects
93
- // go to exactly one caller, so freezing here cannot disturb a second reader.
94
- // The diagnostic stays an engine-minted error and is deliberately not frozen.
95
- /** @type {(problems: any[]) => readonly any[]} */
96
- let freezeProblems = (problems) => Object.freeze(problems.map((problem) => Object.freeze(problem)));
98
+ // The structured problems and warnings, frozen for handing out: the
99
+ // traversal's own objects go to exactly one caller, so freezing here cannot
100
+ // disturb a second reader. The diagnostic stays an engine-minted error and is
101
+ // deliberately not frozen. One helper for both lists, because a warning is a
102
+ // problem's shape less the field that cannot occur on one.
103
+ /** @type {(entries: any[]) => readonly any[]} */
104
+ let freezeEntries = (entries) => Object.freeze(entries.map((entry) => Object.freeze(entry)));
97
105
 
98
106
  // The per-node anchor sets, as frozen host data (CONTEXT.md, "Freeze"): which
99
107
  // anchors and group handles each compiled source reads, keyed by schema path.
@@ -333,8 +341,9 @@ export function quario(options) {
333
341
  let planned = plan(schema, funcs, options);
334
342
  return {
335
343
  report: planned.problems.length ? null : wrap(assemble(planned, schema, marking, options)),
336
- problems: freezeProblems(planned.problems),
344
+ problems: freezeEntries(planned.problems),
337
345
  anchors: freezeAnchors(planned.anchors),
346
+ warnings: freezeEntries(planned.warnings),
338
347
  };
339
348
  },
340
349
  };
package/lib/math.js CHANGED
@@ -15,17 +15,44 @@
15
15
  * table, is in docs/adr/0050. The locale is hardcoded `en-US` rather than the
16
16
  * instance's: this is arithmetic, not presentation, and a locale with
17
17
  * non-ASCII digits or its own grouping would not parse back through `Number`.
18
- * Formatters are built per call, deliberately -- see the ADR.
19
18
  */
19
+ import { boundedMemo } from "./memo.js";
20
+
21
+ // The formatters are memoised behind `./memo.js`, the same seam `format()`
22
+ // uses -- one bounded store each, not one between them. ADR 0050 declined this
23
+ // cache and records why the decline expired; what is here is what only this
24
+ // call site knows, its key and its cap.
25
+ //
26
+ // **The key is the mode and the count**, which is all that varies: the locale
27
+ // and `useGrouping` are constants here rather than configuration, precisely so
28
+ // the answer parses back through `Number`, so they are not in it. The mode
29
+ // leads, as the kind does in `format()`'s key, and the two shapes read the
30
+ // same way. Drop either piece and a `floor` presents a `round`'s answer, or a
31
+ // three-digit call presents two -- pinned a piece at a time in
32
+ // `test/frozen.test.js`.
33
+ //
34
+ // **The cap is unreachable, and that is the point of stating it.** `places`
35
+ // admits 0 to 100 and there are three modes, so this store holds at most 303
36
+ // entries however hostile the data is -- `n` may be an `=` result, but it
37
+ // cannot be an unbounded one. 512 is headroom over that rather than a
38
+ // threshold: if the admitted range ever widened, the store would empty and
39
+ // rebuild rather than grow without limit, which is the same degradation
40
+ // `format()`'s cap buys and no correctness knob either way.
41
+ let memo = boundedMemo(512);
42
+
20
43
  /** @type {(x: number, n: number, mode: string) => number} */
21
44
  let decimal = (x, n, mode) =>
22
45
  Number(
23
- new Intl.NumberFormat("en-US", {
24
- minimumFractionDigits: n,
25
- maximumFractionDigits: n,
26
- useGrouping: false,
27
- roundingMode: /** @type {any} */ (mode),
28
- }).format(x),
46
+ memo(
47
+ mode + "|" + n,
48
+ () =>
49
+ new Intl.NumberFormat("en-US", {
50
+ minimumFractionDigits: n,
51
+ maximumFractionDigits: n,
52
+ useGrouping: false,
53
+ roundingMode: /** @type {any} */ (mode),
54
+ }),
55
+ ).format(x),
29
56
  );
30
57
  // The one test both halves make of `x`. Named because the rule it stands for
31
58
  // is the interesting part: rounding a value that is not a finite number would
@@ -47,16 +74,36 @@ let honourable = (digits) => Number.isFinite(digits) && digits >= 0 && digits <=
47
74
  // `maximumFractionDigits`, an option the author never wrote, so the message is
48
75
  // ours. Fractional `n` truncates rather than throwing, which is what Intl does
49
76
  // with it and is not worth a second rule for an author to hold.
50
- /** @type {(n: any) => number} */
51
- let places = (n) => {
52
- let digits = Math.trunc(+(n ?? 0));
53
- if (!honourable(digits)) throw new RangeError("n must be a number from 0 to 100");
77
+ /** @type {(fn: string, n: any) => number} */
78
+ let places = (fn, n) => {
79
+ let digits = NaN;
80
+ // `+` is the other way `n` fails, and it used to fail rawly: a BigInt or a
81
+ // Symbol makes the coercion throw a TypeError of its own, and an object
82
+ // carrying a hostile `valueOf` throws whatever it likes. Each arrived
83
+ // located at the cell and naming nothing -- the very fault this rule closes
84
+ // -- and render data may be untrusted (SCHEMA.md, "Trust and Content
85
+ // Security Policy"), so an `n` that cannot even be read is folded into the
86
+ // one answer an author can act on rather than surfacing V8's.
87
+ try {
88
+ digits = Math.trunc(+(n ?? 0));
89
+ } catch {
90
+ digits = NaN;
91
+ }
92
+ if (!honourable(digits)) throw new RangeError(fn + "'s n must be a number from 0 to 100");
54
93
  return digits;
55
94
  };
56
95
 
57
- /** @type {(mode: string) => (x: any, n: any) => any} */
58
- let rounder = (mode) => (x, n) => {
59
- let digits = places(n);
96
+ // The name is threaded rather than prefixed by a wrapper around the registry.
97
+ // `places` is the only place a built-in refuses an argument -- `abs` and the
98
+ // folds never do -- so a catch on every built-in call would exist to serve one
99
+ // function's messages. A mechanical prefix would also cost both halves their
100
+ // own sentence: a reducer names itself inside its (`min is a reducer over an
101
+ // array`) where a scalar names an argument it owns (`round's n`), and a
102
+ // uniform `name: ` joiner fits neither without rewriting the other
103
+ // (quario-gr4h).
104
+ /** @type {(fn: string, mode: string) => (x: any, n: any) => any} */
105
+ let rounder = (fn, mode) => (x, n) => {
106
+ let digits = places(fn, n);
60
107
  return finite(x) ? decimal(x, digits, mode) : x;
61
108
  };
62
109
  /** @type {Record<string, (...args: any[]) => any>} */
@@ -64,8 +111,8 @@ export let MATH = {
64
111
  // Written `(x, n)` rather than `(x, n = 0)` on purpose: xprsn's signatures()
65
112
  // reports arity from `fn.length`, and a default parameter would describe
66
113
  // these to an editor as taking one argument.
67
- round: rounder("halfExpand"),
68
- floor: rounder("floor"),
69
- ceil: rounder("ceil"),
114
+ round: rounder("round", "halfExpand"),
115
+ floor: rounder("floor", "floor"),
116
+ ceil: rounder("ceil", "ceil"),
70
117
  abs: (x) => (finite(x) ? Math.abs(x) : x),
71
118
  };
package/lib/memo.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The bounded store the engine's `Intl` call sites are memoised behind.
3
+ *
4
+ * Building an `Intl` formatter costs about 20 µs and using one costs about
5
+ * 0.4 µs, so every path that builds one per call spends fifty times what it
6
+ * needs to. Two do: `./format.js` presents a token at the markup edge, and
7
+ * `./math.js` reaches a decimal through a formatter's `roundingMode`
8
+ * (docs/adr/0050). One rule, stated once, rather than the same six lines
9
+ * twice.
10
+ *
11
+ * **A factory, so each site owns its store.** One shared map would let a
12
+ * report turning over thousands of currency formatters evict the three a
13
+ * rounder ever holds, which is the caller with no unbounded key space paying
14
+ * for the caller that has one. The cap is passed rather than fixed here for
15
+ * the same reason: it is a claim about a key space, and only the call site
16
+ * knows what its keys are made of.
17
+ *
18
+ * **Past the cap the store is emptied rather than grown.** A workload with
19
+ * more distinct keys than the cap pays what it paid before any of this and
20
+ * never more, and it can never be handed a value built for another key,
21
+ * because clearing forgets rather than reuses.
22
+ *
23
+ * **The seam is the store, not the key.** The two keys have no shape in
24
+ * common -- four pieces off a resolved style declaration against a rounding
25
+ * mode and a count -- so a shared builder would be a joiner both callers
26
+ * already have in `+`, and it would put the piece that decides correctness a
27
+ * module away from the site that knows what the pieces are. They were
28
+ * designed together and each says so; nothing else about them is shared.
29
+ *
30
+ * **A `make` that throws caches nothing.** It reaches the caller's own catch
31
+ * and a later call asks again, rather than finding nothing sitting under a
32
+ * good key. That is why a miss is `undefined` rather than `has()`:
33
+ * `./precision.js` deliberately caches an *absent* answer and so needs
34
+ * `has()`, which is why it is not on this seam.
35
+ */
36
+
37
+ /** @type {(cap: number) => (key: string, make: () => any) => any} */
38
+ export let boundedMemo = (cap) => {
39
+ /** @type {Map<string, any>} */
40
+ let store = new Map();
41
+ return (key, make) => {
42
+ let value = store.get(key);
43
+ if (value !== undefined) return value;
44
+ value = make();
45
+ if (store.size >= cap) store.clear();
46
+ store.set(key, value);
47
+ return value;
48
+ };
49
+ };