@variance-authority/core 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,41 +1,153 @@
1
- <p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
1
+ <p align="center"><img src="https://variance-authority.dev/mark.svg" alt="Variance Authority mark" width="72"></p>
2
2
 
3
3
  # @variance-authority/core
4
4
 
5
5
  > The Variance Authority format, rules, comparison, attribution and verdicts. Pure data in, pure data out, no DOM and no I/O.
6
6
 
7
- A **subject** is whatever is under test — a component, a page, or a plain
8
- value. A **capture** is the raw material a collector records from it once,
9
- before anything is compared. Collectors extract captures; this package
10
- normalizes and adjudicates them, and never captures anything itself.
7
+ Part of [Variance Authority](https://variance-authority.dev).
8
+
9
+ ## What this is for
10
+
11
+ You already have a reading of something — a DOM recorded by a collector, a
12
+ rendered document, a per-pixel diff mask, or a plain JSON value — and you need
13
+ to know what changed in it, which component and file the change lands on, and
14
+ whether anyone should mind. This package does that part and only that part. It
15
+ opens no DOM, reads no files, makes no network calls, and has no runtime
16
+ dependencies: what you hand it is everything it sees.
17
+
18
+ A **subject** is one named UI state you asked for and can ask for again — a
19
+ component in a given state, a page at a given route, or a plain value. A
20
+ **capture** is the raw material a collector records from a subject once, before
21
+ anything is compared. Collectors produce captures; this package normalizes and
22
+ adjudicates them, and captures nothing itself.
23
+
24
+ ## Requirements
25
+
26
+ Node 22 or newer, and ESM. There is no CommonJS build, so `require()` will not
27
+ load it. No runtime dependencies — every input arrives from a package you
28
+ install separately, named at each example below.
29
+
30
+ ## Install, and compare a value
31
+
32
+ The shortest complete path needs no browser and nothing else installed.
11
33
 
12
34
  ```bash
13
35
  npm install --save-dev @variance-authority/core
14
36
  ```
37
+
38
+ ```ts
39
+ import { shapeValue } from '@variance-authority/core/format';
40
+ import { compareValues } from '@variance-authority/core/compare';
41
+
42
+ const shaping = { arrayKey: { '/rows': 'id' } };
43
+
44
+ const baseline = shapeValue(
45
+ { rows: [{ id: 'checkout', total: 10 }, { id: 'cart', total: 4 }] },
46
+ shaping,
47
+ );
48
+ const candidate = shapeValue(
49
+ { rows: [{ id: 'checkout', total: 12 }, { id: 'cart', total: 4 }, { id: 'search', total: 1 }] },
50
+ shaping,
51
+ );
52
+
53
+ console.log(baseline);
54
+ console.log(compareValues(baseline, candidate));
55
+ ```
56
+
57
+ ### What you get
58
+
59
+ ```
60
+ {
61
+ dialect: 'json',
62
+ text: '{"rows":{"cart":{"id":"cart","total":4},"checkout":{"id":"checkout","total":10}}}',
63
+ digest: 'v1:e19dcb366e2321d0c7890dd69629ac39',
64
+ recipe: 'value/1',
65
+ keyed: [ '/rows' ]
66
+ }
67
+ [
68
+ {
69
+ change: 'value-changed',
70
+ pointer: '/rows/checkout/total',
71
+ fingerprint: 'v1:444e8245747e15412c5142a5a44fe321'
72
+ },
73
+ {
74
+ change: 'added',
75
+ pointer: '/rows/search',
76
+ fingerprint: 'v1:a9bd40864996d0b80d37c034f3f62109'
77
+ }
78
+ ]
79
+ ```
80
+
81
+ Three things in that output do work you will want later.
82
+
83
+ `text` is the canonical serialization, byte for byte what `digest` was taken
84
+ over — keys sorted, numbers written portably, `undefined` omitted. It is what a
85
+ baseline stores and what a person reads in a pull request.
86
+
87
+ `arrayKey` turned the `rows` array into an object keyed by `id` before
88
+ canonicalization, which is why inserting `search` at the end reports one
89
+ `added` delta. Compared by index instead, an insertion at the top of a
90
+ two-thousand-row list reports two thousand rows as changed.
91
+
92
+ `fingerprint` is the shape of the difference with the row identity and the
93
+ values removed. Change `cart.total` instead of `checkout.total` and you get the
94
+ same `v1:444e8245747e15412c5142a5a44fe321`, so one decision can settle the same
95
+ edit wherever it occurred. Deltas record no values at all: the two canonical
96
+ texts are the evidence, and a delta that quoted them would put payload into
97
+ every record that accumulates.
98
+
99
+ `compareValues` throws rather than guessing when the two sides disagree about
100
+ how they were made — different dialects, different shaping rules, or different
101
+ keyed arrays. `shapeValue` throws on anything that cannot be canonical text,
102
+ naming the JSON Pointer:
103
+
104
+ ```
105
+ the value at /at is a Date, which is not a value. Its state is not in its own
106
+ enumerable keys, so it would serialize to `{}` and two different ones would
107
+ compare as unchanged.
108
+ ```
109
+
110
+ Functions, `Date`, `bigint` and non-finite numbers all fail this way. The
111
+ alternative is `JSON.stringify`'s: drop the key silently, which the next run
112
+ reads as a key that was removed.
113
+
114
+ ### Shaping options
115
+
116
+ | option | effect |
117
+ |---|---|
118
+ | `arrayKey` | `{ '/rows': 'id' }` — the member that identifies a row, so an array becomes an object and order stops being a fact about it |
119
+ | `drop` | records a pointer's value as the token `[dropped]` — present, never compared |
120
+ | `replace` | puts a stable token of your choosing at a pointer |
121
+ | `dialect` | a label saved with the text, `'json'` by default; two values with different dialects refuse to compare |
122
+ | `generator` | `{ name, version }` of whatever emitted the value |
123
+
124
+ Every key is a JSON Pointer (RFC 6901). A `-` token matches any array index, so
125
+ `/items/-/updatedAt` names that member of every item. Neither `dialect` nor
126
+ `generator` counts toward the digest, so labelling a value later does not orphan
127
+ its baseline.
128
+
15
129
  ## Use this package when
16
130
 
17
131
  Install `@variance-authority/core` when the input is already produced: a
18
- capture, a semantic snapshot, a render document (serialized subject markup,
19
- ready to be rasterized elsewhere), a raster mask (`ChangeMask`: a per-pixel
20
- changed/unchanged bitmap, not an image), or a plain value. The package does
21
- not collect a DOM, read PNG bytes, launch a renderer, or select a test
22
- runner — for that, install `@variance-authority/dom` for a live DOM,
23
- `@variance-authority/react` for React provenance (the chain of component
24
- ownership attached to each node), and `@variance-authority/png` when the
25
- input is a PNG.
132
+ capture, a **semantic snapshot** (a normalized tree of nodes, digests and
133
+ boxes), a **render document** (serialized subject markup, ready to be
134
+ rasterized elsewhere), a **change mask** (`ChangeMask`: a per-pixel
135
+ changed/unchanged bitmap, not an image), or a plain value.
136
+
137
+ To produce those inputs, install a collector beside it:
138
+
139
+ ```bash
140
+ npm install --save-dev @variance-authority/dom # collect() a live DOM into a capture
141
+ npm install --save-dev @variance-authority/react # React component ownership per node
142
+ npm install --save-dev @variance-authority/png # comparePngs() two PNGs into a ChangeMask
143
+ ```
26
144
 
27
145
  ## Entrypoints
28
146
 
29
- Nine groups. Five of them are the order an answer travels through; `core/plan`
30
- and `core/relate` sit outside that line, because both are asked *before* anything
31
- is captured — one decides which baselines the run can reach at all, the other
32
- decides which subjects are worth reaching for. `core/segment` and `core/share`
33
- sit outside it in the other direction: they are how a derived answer is written
34
- down and handed to another machine, and neither knows what it is carrying. Every
35
- name lives in exactly one group, and the bare specifier holds only the capture
36
- artifact.
147
+ Every name lives in exactly one group, and the bare `@variance-authority/core`
148
+ specifier exports only the capture artifact.
37
149
 
38
- | entrypoint | holds |
150
+ | entrypoint | exports |
39
151
  |---|---|
40
152
  | `core/format` | what a subject *is*: capture, snapshot, document, identity, hashing |
41
153
  | `core/rules` | the versioned opinions: allowlist, applicability, cascade, canonicalization |
@@ -47,130 +159,144 @@ artifact.
47
159
  | `core/segment` | columnar bytes: named columns, interned strings, and the validation a decode performs before it believes a file |
48
160
  | `core/share` | leaving those bytes where another machine finds them, under a key that is a commit |
49
161
 
50
- Three terms recur across those groups. A **profile** records what a collector
51
- was capable of observing — jsdom sees structure and declared style, chromium
52
- adds layout and pixels. An **identity** is the content hash that addresses a
53
- result; two runs are comparable only when their identities match. A
54
- **verdict** is one of six words this package will commit to: `unchanged`,
55
- `inherited`, `authorized`, `needs-review`, `violation`, `unexplained`.
56
-
57
- The groups exist for callers who genuinely want one. Somebody implementing the
58
- capture format for a renderer this project has never met needs `core/format` and
59
- would be misled by everything else. Somebody deciding where a baseline is stored,
60
- or whether two runs may be compared at all, needs `core/plan` and nothing else:
61
- the digest it derives is the address, so changing any part of the plan changes
62
- which baselines the run can see.
63
-
64
- ## Comparison and policy
65
-
66
- `compare` says **what changed**. `judge` says **whether anyone should mind**.
67
-
68
- Between them sits `partingOf`, which says **which input moved**. Given two
69
- snapshots carrying holdings it walks the component boundaries for the shallowest
70
- one whose inputs agreed and whose output did not, and reports that as the origin
71
- — a moved prop, a context, an external store, or a hook cell by call position.
72
- It leads with a slice (`variation`, `flake`, `refactor`, `absorbed`, `settled`,
73
- `unread`) so a reader knows whether to open the rest, and `explainParting` turns
74
- the whole thing into lines a person reads.
75
-
76
- ## Smallest working path: compare a value
77
-
78
- Value comparison needs no host setup and returns paths and fingerprints, not a
79
- pass/fail verdict:
80
-
81
- ```ts
82
- import { compareValues } from '@variance-authority/core/compare';
83
- import { shapeValue } from '@variance-authority/core/format';
162
+ Five of those are the order an answer travels through: `format`, `rules`,
163
+ `compare`, `attribute`, `judge`. `plan` and `relate` are asked before anything
164
+ is captured — one decides which baselines a run can use, the other which
165
+ subjects are worth reading. `segment` and `share` are how a derived answer
166
+ is written down and handed to another machine.
167
+
168
+ ## The four words the output uses
169
+
170
+ A **profile** records what a collector was capable of observing. `jsdom` sees
171
+ structure and declared style; `chromium` adds layout and pixels. A profile that
172
+ cannot measure a box cannot decide pixels either.
173
+
174
+ An **identity** is the content hash that addresses a result, derived from the
175
+ whole plan — ruleset version, viewport, policy, interventions. Two runs are
176
+ comparable only when their identities match, so changing any part of the plan
177
+ changes which baselines the run can see.
178
+
179
+ A **band** is the kind of change a delta is, and which band a delta lands in
180
+ decides how loudly it is reported. Change frequency and change importance run
181
+ opposite each other: an accessible name almost never changes and is a defect
182
+ when it does, while anti-aliasing changes constantly and never matters. There
183
+ are five, loudest first:
184
+
185
+ | band | what changed | example delta kinds |
186
+ |---|---|---|
187
+ | `a11y` | a role, accessible name, description or ARIA state | `role-changed`, `name-changed`, `state-changed` |
188
+ | `geometry` | boxes appeared, vanished, moved or resized | `node-added`, `node-moved`, `rect-changed`, `attribute-changed` |
189
+ | `token` | style values changed while structure stayed the same | `style-changed`, `token-changed` |
190
+ | `content` | text changed and nothing else did | `text-changed` |
191
+ | `texture` | sub-semantic rendering variance | `raster-residue` |
192
+
193
+ `bandOf(kind)` performs that assignment and is the default rather than the
194
+ answer — policy may promote or demote per project, subject, region or
195
+ component. `loudestBand(bands)` collapses a set to the loudest one present, and
196
+ returns `null` for an empty set so that *nothing changed* and *something changed
197
+ at the quietest band* stay apart.
198
+
199
+ A **verdict** is the one word a result reports, and the whole pipeline exists to
200
+ produce one with a reason attached. There are six, by severity:
201
+
202
+ | verdict | means |
203
+ |---|---|
204
+ | `unchanged` | hash hit, or never reachable from the change |
205
+ | `inherited` | fully explained by an adjudication that already happened upstream |
206
+ | `authorized` | matches declared intent, or policy auto-approves this band |
207
+ | `needs-review` | band and policy require a human to sign off |
208
+ | `violation` | policy forbids it |
209
+ | `unexplained` | raster residue with no semantic cause — the highest severity here, because a change the pipeline cannot explain means attribution failed |
84
210
 
85
- const before = shapeValue({ rows: [{ id: 'a', total: 10 }] }, {
86
- arrayKey: { '/rows': 'id' },
87
- });
88
- const after = shapeValue({ rows: [{ id: 'a', total: 12 }] }, {
89
- arrayKey: { '/rows': 'id' },
90
- });
211
+ Beside those sits `UNOBSERVED`, which is what a band reports when the acting
212
+ profile could not see it. It is not a verdict and the types will not let you
213
+ spell it as one.
91
214
 
92
- const deltas = compareValues(before, after);
93
- console.log(deltas[0]?.pointer); // /rows/a/total
94
- ```
215
+ ## Compare a rendered subject
95
216
 
96
- For a rendered subject, the same package receives captures from a collector:
217
+ Excerpt. `capture` and `recapture` are what `collect(root, options)` from
218
+ `@variance-authority/dom` returns for the same subject at two revisions; `mask`
219
+ is the `mask` field of `comparePngs(before, after)` from
220
+ `@variance-authority/png`. Core never opens an image.
97
221
 
98
222
  ```ts
99
- import { attributeRegions, isolateRegions } from '@variance-authority/core/attribute';
100
- import { diffSnapshots } from '@variance-authority/core/compare';
101
223
  import { normalize } from '@variance-authority/core/rules';
224
+ import { diffSnapshots } from '@variance-authority/core/compare';
225
+ import { attributeRegions, isolateRegions } from '@variance-authority/core/attribute';
102
226
 
103
- const before = normalize(capture); // supplied by a collector
227
+ const before = normalize(capture);
104
228
  const after = normalize(recapture);
105
- const diff = diffSnapshots(before, after); // deltas, roots, no verdict
229
+ const diff = diffSnapshots(before, after); // deltas and roots, no verdict
106
230
 
107
- // `mask` is a ChangeMask from the raster tier. Core never opens an image.
108
231
  const places = isolateRegions(mask, { cell: 8 }); // pixels → regions
109
232
  const named = attributeRegions(places.regions, after, { scale: 2 });
110
233
  ```
111
234
 
112
- The value example prints a JSON Pointer for the changed field. The rendered path
113
- returns semantic roots and, when a raster mask is supplied, regions attributed to
114
- the candidate snapshot. A caller still chooses policy and a verdict in
115
- `core/judge` or in its own runner.
235
+ `diffSnapshots` returns `identical`, the flat `deltas`, the `roots` a docket
236
+ renders and approves, and the `components` implicated, separated into causes and
237
+ collateral — so "`Button` changed, and ten components render it" replaces
238
+ "eleven components changed". `attributeRegions` turns regions into components
239
+ and files. Choosing a verdict is a separate step, in `core/judge` or in your own
240
+ runner.
116
241
 
117
- `scale` is device pixels per CSS pixel. It is **required**, and it has no default
118
- on purpose: a 2x screenshot attributed at 1x lands every region in the top-left
119
- quadrant and names the wrong component for each — a full, plausible, entirely
120
- wrong report. That last step is where regions become components and files.
242
+ `scale` is device pixels per CSS pixel and it is required. Attribute a 2x
243
+ screenshot at 1x and every region lands in the top-left quadrant with the wrong
244
+ component named — a full, plausible, entirely wrong report.
121
245
 
122
- The options that change scope are explicit at the call site:
246
+ Rendered comparisons throw when the two subjects differ, or when their
247
+ observation profiles differ.
248
+
249
+ ### Options that change scope
123
250
 
124
251
  | call | useful controls |
125
252
  |---|---|
126
253
  | `normalize` | `collapseWrappers` removes layout-only wrapper boundaries, `digestText` includes text content, and `sourceRoot` relativizes source locations |
127
254
  | `isolateRegions` / `attributeRegions` | `cell` and `limit` bound mask work; `origin` and `containment` describe the coordinate origin and how much a node must contain a region |
128
- | `fingerprintOfMask` | `grid` controls the shape sample and `coverage` controls the minimum occupied share |
255
+ | `fingerprintOfMask` | `grid` controls the shape sample and `coverage` the minimum occupied share |
129
256
  | `compareLocales` / ignore validation | `slack` permits a declared locale distance; `sites` supplies resolved ignore locations and `now` evaluates expiry |
130
- | `buildDocket` / dependency reach | `sampleSize` limits review examples; `through` selects graph edge kinds, `avoid` names nodes a walk never enters, and `shadows` carries per file the modules its run never reaches, so `movedBy` leaves out a file every trail to which crosses one of its own shadows |
257
+ | `buildDocket` / dependency reach | `sampleSize` limits review examples; `through` selects graph edge kinds, `avoid` names nodes a walk never enters, and `shadows` lists per file the modules its run never reaches, so `movedBy` leaves out a file every trail to which crosses one of its own shadows; `depends` adds the install — which package rests on which — so a dependency bump is a seed like any other and affects only the files that import it |
258
+ | `beforeReach` | `sensed` names the directories the scan already answers for, and the descent from a declared entry point stops at the first file under one of them rather than dragging the repository's own source in behind the harness |
131
259
  | `sharedClosures` | `floor` drops a shared subtree below a node count |
132
260
  | `lexiconOf` | `examples`, `declaredIn` and `regions` supply what the instances cannot: which components a subject is the example of, the files declaring each component, and the regions its journey entered |
133
261
  | report summaries | `source` maps component names to files when `summarizeAdjudication` or `summarizeFindings` needs an actionable path |
134
262
  | screenshot stabilization | `animations` and `caret` are explicit intervention settings; omitted means the caller did not assert either intervention |
135
263
 
136
- ### A subject that was never rendered
264
+ ## Which input changed
137
265
 
138
- `shapeValue(value, options)` is the same treatment for a value — an API
139
- response, a generated schema, a route table. It canonicalizes (keys sorted,
140
- numbers written portably, `undefined` omitted), addresses the text by content,
141
- and returns a `CapturedValue`; `compareValues` turns two of them into deltas
142
- carrying a JSON Pointer and a fingerprint, and — like everything else here — no
143
- verdict.
266
+ `compare` says what changed. `judge` says whether anyone should mind. Between
267
+ them, `partingOf` says which input changed.
144
268
 
145
- ```ts
146
- import { compareValues } from '@variance-authority/core/compare';
147
- import { shapeValue } from '@variance-authority/core/format';
269
+ Given two snapshots with holdings — the inputs a collector recorded at each
270
+ component boundary — it walks the boundaries for the shallowest one whose inputs
271
+ agreed and whose output did not, and reports that as the origin: a changed prop,
272
+ a context, an external store, or a hook cell by call position. `explainParting`
273
+ turns the result into lines a person reads.
148
274
 
149
- const baseline = shapeValue(before, { arrayKey: { '/rows': 'id' } });
150
- const deltas = compareValues(baseline, shapeValue(after, { arrayKey: { '/rows': 'id' } }));
151
- ```
275
+ It leads with a slice, so you know whether to open the rest:
152
276
 
153
- `drop` records a pointer's value as present without comparing it, `replace` puts
154
- a token you choose in its place, and `arrayKey` says which member identifies a
155
- row — the difference between *one row was added* and *two thousand rows moved*.
156
- Two more options describe the value rather than shape it: `dialect` is how a
157
- reader will interpret the text later (`'json'` by default, `'openapi'` and
158
- `'graphql'` being the ones with detectors), and `generator` names what emitted
159
- it. Neither reaches the digest, so declaring a dialect does not orphan a
160
- baseline.
277
+ | slice | means |
278
+ |---|---|
279
+ | `settled` | nothing changed: not the tree, not an input, not the output |
280
+ | `variation` | an input changed and the output followed — the ordinary case, and the only one where the detail below is worth reading |
281
+ | `absorbed` | an input changed and the output did not; the component ignored it |
282
+ | `refactor` | the component tree changed and the output did not |
283
+ | `reshaped` | the tree is a different tree, no input changed, and the output followed |
284
+ | `flake` | every input agreed, the tree matched, and the output changed anyway |
285
+ | `placed` | the same, but between two readings taken in different places |
286
+ | `unread` | the output changed and what would explain it was not read |
161
287
 
162
- Non-data throws, naming the pointer. A function, a `Date`, a `bigint` or a
163
- non-finite number cannot be canonical text, and dropping one silently puts a key
164
- in the record that the next run reads as removed.
288
+ `flake` is an accusation against a page, so it is never returned on silence. A
289
+ run that recorded no component provenance gets `unread` instead.
165
290
 
166
- Rendered comparisons also throw when the subjects or observation profiles differ.
291
+ ## Hand an answer to another machine
167
292
 
168
- ## Handing an answer to another machine
293
+ What a suite is made of is a fact about a commit rather than about a run.
294
+ `core/segment` writes such a thing as columns — equal facts encode to equal
295
+ bytes, which is what lets a transport skip an upload — and `core/share` moves
296
+ the bytes.
169
297
 
170
- A run derives what the suite is made of, and that is a fact about a commit
171
- rather than about the run. `core/segment` writes such a thing as columns — equal
172
- facts encode to equal bytes, which is what lets a transport skip an upload — and
173
- `core/share` moves the bytes:
298
+ Excerpt: `token` is your store's credential and `bytes` is what `core/segment`
299
+ encoded.
174
300
 
175
301
  ```ts
176
302
  import { httpShare, shareKey } from '@variance-authority/core/share';
@@ -184,28 +310,29 @@ const share = httpShare({
184
310
  await share.put(shareKey({ project: 'web', artifact: 'suite-index-v1', commit }), bytes);
185
311
  ```
186
312
 
187
- `endpoint` is a base URL a key is appended to; `headers` is sent on every
188
- request, which is where a bucket's `Authorization` or a deployment's token goes;
313
+ `endpoint` is a base URL a key is appended to. `headers` is sent on every
314
+ request, which is where a bucket's `Authorization` or a deployment's token goes.
189
315
  `method` is the verb a write uses — `PUT` for a bucket, `POST` for a deployment
190
- that routes on it. A presigned base needs none of the three but the first.
316
+ that routes on it. A presigned base needs only the endpoint.
191
317
 
192
318
  **A share never throws.** A miss, an outage, a permission error and a body
193
319
  nobody can parse are one outcome: `get` answers `null` and `put` resolves.
194
- Everything a share holds was derived from a tree and can be derived again, so
195
- the worst a broken one does is cost the derivation — which also means a failing
196
- share is indistinguishable from a cold one, and only the wall clock says so.
197
- [Sharing an evaluation](../../docs/sharing.md) is the operator's side of it.
320
+ Everything a share stores can be derived again, so a broken share costs you the
321
+ derivation — and is indistinguishable from a cold one except by the wall clock.
322
+ [Sharing an evaluation](https://variance-authority.dev/docs/sharing) is the
323
+ operator's side of it.
198
324
 
199
325
  ## What it refuses
200
326
 
201
- **Absent is not empty.** Not measured, measured as zero, and unobservable are
202
- three states, and collapsing any two produces a pass nobody earned. A **band**
203
- (the frequency category a change falls into — `a11y`, `geometry`, `token`,
204
- `content`, `texture`, rarest to noisiest) that a profile cannot see reports
205
- `UNOBSERVED`, which the type system will not let you spell the same way as a
206
- pass.
327
+ **Absent is not empty.** Not measured, measured as zero, and unobservable stay
328
+ three distinct states through every type in this package. A band the profile
329
+ could not see reports `UNOBSERVED`, and a geometry digest is absent rather than
330
+ empty under a profile without layout.
331
+
332
+ **Two results whose identities differ never compare.** A ruleset bump, a
333
+ different viewport, a different intervention — each changes the identity, and
334
+ the comparison is refused rather than reported as a difference in the product.
207
335
 
208
- **Two results whose identities differ are `incomparable`, never `different`.**
209
- A difference in conditions reported as a difference in the product is a
210
- confident wrong answer, and the confidence is what makes it expensive.
336
+ ---
211
337
 
338
+ **[@variance-authority/core](https://variance-authority.dev/reference/packages/core)** is part of [Variance Authority](https://variance-authority.dev) — [documentation](https://variance-authority.dev/docs) · MIT
@@ -1,4 +1,4 @@
1
- import type { Provenance, SourceLocation, StackFrame } from '../format/provenance.js';
1
+ import { type Provenance, type SourceLocation, type StackFrame } from '../format/provenance.js';
2
2
  import type { NodePath, SemanticSnapshot } from '../format/snapshot.js';
3
3
  /**
4
4
  * Spending the frames a page read, to get the file a reviewer opens.
@@ -33,6 +33,24 @@ import type { NodePath, SemanticSnapshot } from '../format/snapshot.js';
33
33
  */
34
34
  /** Fetch a module's text, or answer that it cannot be had. Never throws. */
35
35
  export type FetchModule = (url: string) => Promise<string | null>;
36
+ export interface CallSiteOptions {
37
+ /**
38
+ * The repository root, for a location that arrives holding an absolute path.
39
+ *
40
+ * Two kinds of frame reach {@link CallSiteResolver.locate} and only one of
41
+ * them is relative already. A frame that needed a map is answered in the map's
42
+ * own terms, which are the build's and so the repository's. A frame that
43
+ * needed none — a Node stack, or the synthetic frame React manufactures for a
44
+ * server component — is answered in the terms of the disk it was captured on,
45
+ * which means somebody's home directory in the middle of a path that is
46
+ * otherwise a fact about the project.
47
+ *
48
+ * Given, the two shapes come out as one. Absent, a location is returned as it
49
+ * arrived, because guessing at a root is how a path stops resolving for the
50
+ * reader who opens it.
51
+ */
52
+ readonly sourceRoot?: string;
53
+ }
36
54
  export interface CallSiteResolver {
37
55
  /** The location that wrote an element, from the frames its fiber carried. */
38
56
  locate(frames: readonly StackFrame[]): Promise<SourceLocation | null>;
@@ -47,7 +65,7 @@ export interface CallSiteStats {
47
65
  /** Call sites that resolved to a file. */
48
66
  readonly located: number;
49
67
  }
50
- export declare function createCallSiteResolver(fetchModule: FetchModule): CallSiteResolver;
68
+ export declare function createCallSiteResolver(fetchModule: FetchModule, options?: CallSiteOptions): CallSiteResolver;
51
69
  /**
52
70
  * Provenance with its frames spent: `source` filled in, `stack` gone.
53
71
  *
@@ -1,6 +1,7 @@
1
+ import { relativizeSource, } from '../format/provenance.js';
1
2
  import { inlineSourceMapOf, originalPositionFor, parseSourceMap, sourceMappingUrlOf, } from './source-map.js';
2
- import { isVendorPath, writerLocationOf } from './stack.js';
3
- export function createCallSiteResolver(fetchModule) {
3
+ import { isVendorPath, servedPath, writerLocationOf } from './stack.js';
4
+ export function createCallSiteResolver(fetchModule, options = {}) {
4
5
  /** Module URL → its map, `null` once we know it has none. Also the in-flight guard. */
5
6
  const maps = new Map();
6
7
  /** `url:line:column` → the answer, so a page's 4211 nodes cost 14 resolutions. */
@@ -34,10 +35,11 @@ export function createCallSiteResolver(fetchModule) {
34
35
  }
35
36
  async function locateFrame(frame) {
36
37
  const parsed = await mapsFor(frame.url);
37
- // No map is not failure. A dev server serving a plain `.js` as written, and a
38
- // Node test runner — which applies source maps to `Error.stack` itself, so
39
- // the frame arrives already original — both land here with the answer in
40
- // hand. `writerLocationOf` keeps the frame's own coordinates in that case.
38
+ // No map is not failure, and it is not an answer either. A Node test runner
39
+ // applies source maps to `Error.stack` itself, so its frame arrives already
40
+ // original and there is nothing left to add. A served module carries no such
41
+ // guarantee: a position in the text a server sent is a position in the
42
+ // repository only if a map says so, so `writerLocationOf` refuses it.
41
43
  if (parsed === null || parsed.length === 0)
42
44
  return null;
43
45
  const original = originalPositionFor(parsed, frame.line, frame.column);
@@ -61,8 +63,8 @@ export function createCallSiteResolver(fetchModule) {
61
63
  sites.set(key, answer);
62
64
  }
63
65
  const located = await answer;
64
- // The frame's own coordinates, for a module served as written. Delegated so
65
- // there is one statement of the choosing rule rather than two.
66
+ // The frame's own coordinates, for a frame that names a file on disk.
67
+ // Delegated so there is one statement of the choosing rule rather than two.
66
68
  const chosen = located === null
67
69
  ? writerLocationOf([frame], () => null)
68
70
  : isVendorPath(located.file)
@@ -70,7 +72,9 @@ export function createCallSiteResolver(fetchModule) {
70
72
  : located;
71
73
  if (chosen !== null) {
72
74
  stats.located += 1;
73
- return chosen;
75
+ return options.sourceRoot === undefined
76
+ ? chosen
77
+ : relativizeSource(chosen, options.sourceRoot);
74
78
  }
75
79
  }
76
80
  return null;
@@ -152,19 +156,12 @@ function index(root) {
152
156
  * A map's `sources` entry as a path, resolved against the module it describes.
153
157
  *
154
158
  * Maps state sources relatively — Vite writes `probe.jsx` for `/src/probe.jsx` —
155
- * so the entry alone is ambiguous between two directories. Resolved and then
156
- * stripped to a path, because the origin is a fact about the machine that ran
157
- * the capture: a baseline holding `http://localhost:5199/src/probe.jsx` would
158
- * disagree with the next run on a different port. What is left is what the
159
- * source index and `relativizeSource` already speak.
159
+ * so the entry alone is ambiguous between two directories. Resolved against the
160
+ * module first, then stripped of the origin it resolved against, which is a fact
161
+ * about the machine that served it rather than about the code.
160
162
  */
161
163
  function sourcePath(source, moduleUrl) {
162
- const resolved = absolute(source, moduleUrl) ?? source;
163
- const parsed = parseUrl(resolved);
164
- // Not a URL: a bundler that wrote an absolute filesystem path, or a
165
- // `webpack://` specifier. Left as it is — `relativizeSource` handles the
166
- // first, and inventing a shape for the second would be a guess.
167
- return parsed === null ? resolved : parsed.pathname.replace(/^\/+/, '');
164
+ return servedPath(absolute(source, moduleUrl) ?? source);
168
165
  }
169
166
  function absolute(url, base) {
170
167
  return parseUrl(url, base)?.href ?? null;