@variance-authority/core 0.1.1 → 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.
Files changed (96) hide show
  1. package/CHANGELOG.md +245 -10
  2. package/README.md +279 -110
  3. package/dist/attribute/call-site.d.ts +20 -2
  4. package/dist/attribute/call-site.js +17 -20
  5. package/dist/attribute/call-site.js.map +1 -1
  6. package/dist/attribute/closure.d.ts +105 -0
  7. package/dist/attribute/closure.js +180 -0
  8. package/dist/attribute/closure.js.map +1 -0
  9. package/dist/attribute/composition.d.ts +5 -3
  10. package/dist/attribute/composition.js +67 -11
  11. package/dist/attribute/composition.js.map +1 -1
  12. package/dist/attribute/divergence.js +7 -1
  13. package/dist/attribute/divergence.js.map +1 -1
  14. package/dist/attribute/index.d.ts +7 -1
  15. package/dist/attribute/index.js +4 -1
  16. package/dist/attribute/index.js.map +1 -1
  17. package/dist/attribute/landmark.d.ts +97 -0
  18. package/dist/attribute/landmark.js +144 -0
  19. package/dist/attribute/landmark.js.map +1 -0
  20. package/dist/attribute/lexicon.d.ts +112 -0
  21. package/dist/attribute/lexicon.js +210 -0
  22. package/dist/attribute/lexicon.js.map +1 -0
  23. package/dist/attribute/source.d.ts +19 -2
  24. package/dist/attribute/source.js +12 -0
  25. package/dist/attribute/source.js.map +1 -1
  26. package/dist/attribute/stack.d.ts +62 -1
  27. package/dist/attribute/stack.js +116 -5
  28. package/dist/attribute/stack.js.map +1 -1
  29. package/dist/compare/derive.d.ts +17 -0
  30. package/dist/compare/derive.js.map +1 -1
  31. package/dist/compare/diff/index.d.ts +25 -1
  32. package/dist/compare/diff/index.js +8 -5
  33. package/dist/compare/diff/index.js.map +1 -1
  34. package/dist/compare/explain.d.ts +1 -1
  35. package/dist/compare/explain.js +15 -8
  36. package/dist/compare/explain.js.map +1 -1
  37. package/dist/compare/index.d.ts +2 -2
  38. package/dist/compare/index.js +1 -1
  39. package/dist/compare/index.js.map +1 -1
  40. package/dist/compare/observability.d.ts +25 -1
  41. package/dist/compare/observability.js +54 -0
  42. package/dist/compare/observability.js.map +1 -1
  43. package/dist/compare/parting.d.ts +24 -2
  44. package/dist/compare/parting.js +14 -9
  45. package/dist/compare/parting.js.map +1 -1
  46. package/dist/compare/slice.d.ts +15 -2
  47. package/dist/compare/slice.js +6 -2
  48. package/dist/compare/slice.js.map +1 -1
  49. package/dist/format/capture.d.ts +15 -0
  50. package/dist/format/capture.js +30 -1
  51. package/dist/format/capture.js.map +1 -1
  52. package/dist/format/document.d.ts +65 -5
  53. package/dist/format/document.js +34 -0
  54. package/dist/format/document.js.map +1 -1
  55. package/dist/format/hash.d.ts +26 -0
  56. package/dist/format/hash.js +41 -0
  57. package/dist/format/hash.js.map +1 -1
  58. package/dist/format/index.d.ts +4 -3
  59. package/dist/format/index.js +3 -2
  60. package/dist/format/index.js.map +1 -1
  61. package/dist/format/sha256.d.ts +10 -2
  62. package/dist/format/sha256.js +42 -9
  63. package/dist/format/sha256.js.map +1 -1
  64. package/dist/format/stabilize.d.ts +15 -0
  65. package/dist/format/stabilize.js +22 -1
  66. package/dist/format/stabilize.js.map +1 -1
  67. package/dist/index.d.ts +3 -11
  68. package/dist/index.js +3 -11
  69. package/dist/index.js.map +1 -1
  70. package/dist/judge/inspect.js +57 -4
  71. package/dist/judge/inspect.js.map +1 -1
  72. package/dist/relate/before.d.ts +137 -0
  73. package/dist/relate/before.js +155 -0
  74. package/dist/relate/before.js.map +1 -0
  75. package/dist/relate/graph.d.ts +53 -16
  76. package/dist/relate/graph.js +33 -15
  77. package/dist/relate/graph.js.map +1 -1
  78. package/dist/relate/index.d.ts +3 -2
  79. package/dist/relate/index.js +2 -1
  80. package/dist/relate/index.js.map +1 -1
  81. package/dist/relate/merkle.d.ts +14 -5
  82. package/dist/relate/merkle.js +14 -6
  83. package/dist/relate/merkle.js.map +1 -1
  84. package/dist/relate/reach.d.ts +19 -5
  85. package/dist/relate/reach.js +10 -8
  86. package/dist/relate/reach.js.map +1 -1
  87. package/dist/relate/records.d.ts +95 -4
  88. package/dist/relate/records.js +93 -13
  89. package/dist/relate/records.js.map +1 -1
  90. package/dist/segment/index.d.ts +123 -0
  91. package/dist/segment/index.js +265 -0
  92. package/dist/segment/index.js.map +1 -0
  93. package/dist/share/index.d.ts +148 -0
  94. package/dist/share/index.js +190 -0
  95. package/dist/share/index.js.map +1 -0
  96. package/package.json +10 -2
@@ -68,6 +68,106 @@ export function isVendorPath(path) {
68
68
  path.includes('/.yarn/') ||
69
69
  path.startsWith('webpack-internal:'));
70
70
  }
71
+ /**
72
+ * A served URL as a path: the origin dropped, the rest kept.
73
+ *
74
+ * An origin is a fact about the machine that ran the capture rather than about
75
+ * the code. A dev server binds an ephemeral port and a preview server binds
76
+ * another, so two runs of one suite would name every location differently, and
77
+ * a cache-busting query would move under the same hand. What is left is what
78
+ * the source index and `relativizeSource` already speak.
79
+ *
80
+ * Anything that does not parse as a URL is left exactly as it is — a bundler
81
+ * that wrote an absolute filesystem path, or a `webpack://` specifier — because
82
+ * inventing a shape for those would be a guess.
83
+ */
84
+ export function servedPath(url) {
85
+ if (URL_OF === undefined)
86
+ return url;
87
+ try {
88
+ return new URL_OF(url).pathname.replace(/^\/+/, '');
89
+ }
90
+ catch {
91
+ return url;
92
+ }
93
+ }
94
+ /**
95
+ * The frame's own position on a disk, or `undefined` if a server sent it.
96
+ *
97
+ * The two arrive in the same field and mean different things when no map
98
+ * answers for them. A browser frame is a position in whatever text the build
99
+ * served, and only a map can say where that text came from. A Node frame is
100
+ * already a position in a file: Node applies maps to `Error.stack` itself, so a
101
+ * Vitest or Jest frame has been resolved before anything here sees it.
102
+ *
103
+ * Decided by the frame's own shape, because that is a property of the value in
104
+ * hand. Nothing here asks a filesystem whether a path is real; a coordinate is
105
+ * ours because of how it was obtained, and a disk cannot be asked about how.
106
+ *
107
+ * Four shapes are a disk. A bare path does not parse as a URL at all. A
108
+ * `file:` URL is a path spelled formally, so it is decoded back into one —
109
+ * whole, including the leading separator a served path sheds, because an
110
+ * absolute path that arrives looking repository-relative is a wrong answer that
111
+ * resolves. A Windows path is checked before either, because `C:\src\probe.ts`
112
+ * parses as a URL whose scheme is the drive letter: a test that asked only
113
+ * whether a frame parsed would call every frame on Windows served, and attribute
114
+ * nothing at all there.
115
+ *
116
+ * **The fourth shape is React's, and it is refused — measured, not assumed.**
117
+ * A component that ran on the server has no fiber in the browser and no module
118
+ * the browser was served, so React manufactures a frame on the client out of
119
+ * coordinates the server put on the wire:
120
+ * `about://React/<environment>/<url>?<n>`. It *looks* like the third shape — a
121
+ * `file:` URL naming a file in the repository — and it is not one. The
122
+ * coordinates inside it are positions in the module the **server** compiled,
123
+ * and that module is not the one the browser can fetch.
124
+ *
125
+ * Reading it as a path was tried against a real RSC route, and the answer was
126
+ * wrong in the way that is worst: plausibly. React's frame for one `<aside>`
127
+ * read `DocsPage.tsx:200`, in a file 146 lines long; the browser's own build of
128
+ * the same source put that element at line 196 of a 268-line module, and
129
+ * resolving *that* through its map landed on line 126 — a different element,
130
+ * four lines from the right answer, with nothing about it to say so.
131
+ *
132
+ * So it falls to the `file:` test below and yields nothing, which is the rule
133
+ * {@link writerLocationOf} already states for every served frame: a position in
134
+ * text the build made is a position in this repository only if a map says so.
135
+ * The component is still named — that comes from the owner chain, not from
136
+ * here — and the source index still answers where it was declared.
137
+ */
138
+ export function writtenPath(url) {
139
+ if (DRIVE.test(url))
140
+ return url;
141
+ if (URL_OF === undefined)
142
+ return url;
143
+ let parsed;
144
+ try {
145
+ parsed = new URL_OF(url);
146
+ }
147
+ catch {
148
+ return url;
149
+ }
150
+ if (parsed.protocol !== 'file:')
151
+ return undefined;
152
+ const path = decoded(parsed.pathname);
153
+ return DRIVE.test(path.slice(1)) ? path.slice(1) : path;
154
+ }
155
+ /** `C:\src\probe.ts` and `C:/src/probe.ts`, and inside a `file:` URL's path. */
156
+ const DRIVE = /^[A-Za-z]:[\\/]/;
157
+ function decoded(pathname) {
158
+ try {
159
+ return decodeURIComponent(pathname);
160
+ }
161
+ catch {
162
+ return pathname;
163
+ }
164
+ }
165
+ /**
166
+ * The one host global this file needs, asked for rather than assumed. `core`
167
+ * types no host library (ADR-0001), and taking a URL apart by hand is a bug
168
+ * farm.
169
+ */
170
+ const URL_OF = globalThis.URL;
71
171
  /**
72
172
  * The location that wrote this element, chosen from its stack.
73
173
  *
@@ -80,7 +180,9 @@ export function isVendorPath(path) {
80
180
  *
81
181
  * That is the same answer the recorded-symbol path gives, arrived at from the
82
182
  * other side. Where `jsx-source` is installed it wins, because it is exact
83
- * without a map; this is what a project that installed nothing still gets.
183
+ * without a map; this is what a project that installed nothing still gets — as
184
+ * long as the build serving those frames emits maps. Where it does not, this
185
+ * answers `null`, and a caller with no location is a caller that says so.
84
186
  *
85
187
  * `originalFor` is supplied rather than performed because resolving a frame
86
188
  * means fetching the module the browser was served, and this package may not
@@ -93,11 +195,20 @@ export function writerLocationOf(frames, originalFor) {
93
195
  if (isVendorPath(frame.url))
94
196
  continue;
95
197
  const original = originalFor(frame);
96
- // No map is not the same as a vendor frame. An unmapped application module
97
- // is one a build served as written — which a dev server does for plain `.js`
98
- // — and its own coordinates are already the answer.
198
+ // A served frame with no map is a position in text the build made, and only
199
+ // a map can say where that text came from. It is one fewer candidate rather
200
+ // than a location: naming it anyway would put a coordinate in this
201
+ // repository's basis that nothing in this repository was measured in, which
202
+ // reads exactly like a real one and sends a reviewer to a line at random.
203
+ //
204
+ // A frame that is a path needs no map to have been read. Node applies them
205
+ // to `Error.stack` before a runner ever sees it, so the position arrived
206
+ // already resolved and the only thing left to take off it is the origin.
99
207
  if (original === null) {
100
- return { file: frame.url, line: frame.line, column: frame.column };
208
+ const written = writtenPath(frame.url);
209
+ if (written === undefined)
210
+ continue;
211
+ return { file: written, line: frame.line, column: frame.column };
101
212
  }
102
213
  if (isVendorPath(original.source))
103
214
  continue;
@@ -1 +1 @@
1
- {"version":3,"file":"stack.js","sourceRoot":"","sources":["../../src/attribute/stack.ts"],"names":[],"mappings":"AA+BA;;;;;;;GAOG;AACH,MAAM,QAAQ,GAAG,+CAA+C,CAAC;AAEjE,8EAA8E;AAC9E,MAAM,QAAQ,GAAG,kCAAkC,CAAC;AAEpD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,MAAM,MAAM,GAAiB,EAAE,CAAC;IAEhC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC;QAC3B,IAAI,IAAI,KAAK,EAAE,IAAI,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC;YAAE,SAAS;QAExD,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzD,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QAE7B,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,GAAG,EAAE,EAAE,UAAU,GAAG,EAAE,EAAE,YAAY,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;QACrE,IAAI,GAAG,KAAK,EAAE,IAAI,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC;YAAE,SAAS;QAEpD,MAAM,EAAE,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;QAC9B,MAAM,MAAM,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;QACpC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC;YAAE,SAAS;QAEzE,MAAM,CAAC,IAAI,CAAC;YACV,GAAG;YACH,IAAI,EAAE,EAAE;YACR,MAAM;YACN,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACjE,CAAC,CAAC;IACL,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,CACL,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;QAC7B,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;QAC7B,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC;QACxB,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC,CACrC,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAA6B,EAC7B,WAA2D;IAE3D,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC;YAAE,SAAS;QAEtC,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;QAEpC,2EAA2E;QAC3E,6EAA6E;QAC7E,oDAAoD;QACpD,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YACtB,OAAO,EAAE,IAAI,EAAE,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,CAAC;QACrE,CAAC;QAED,IAAI,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC;YAAE,SAAS;QAE5C,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;IACjF,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["import type { SourceLocation, StackFrame } from '../format/provenance.js';\nimport type { OriginalPosition } from './source-map.js';\n\n/**\n * Reading a call site out of a stack, and deciding which frame is the author.\n *\n * React 19 constructs an `Error` inside its own `jsx`/`jsxDEV` and keeps it on\n * every fiber. That error is a complete answer to \"which line wrote this\n * element\" that no build has to opt into: the runtime that captured it is frame\n * zero, and whoever called it is frame one. Nothing is configured, nothing is\n * installed, and the location is present in any build where React is the\n * development build — which is every dev server, every Vitest run and every Jest\n * run, because all three are what \"not production\" means.\n *\n * Two jobs live here and they are separable on purpose. Parsing a stack is a\n * fact about a JavaScript engine. Choosing *which* frame is the author is a\n * policy, and it is the part with a defensible rule rather than a heuristic:\n * the author is the first frame that maps back to code the project wrote. A\n * custom JSX runtime — Emotion, theme-ui — sits between React and the author and\n * is skipped not because it is on a list but because it resolves into\n * `node_modules`.\n */\n\n/**\n * A frame is a wire value, not a local one: the page reads it, the collector\n * resolves it, and it travels between them on `Provenance`. It is declared where\n * the rest of provenance is declared, and re-exported here because this is the\n * group that does something with it.\n */\nexport type { StackFrame };\n\n/**\n * V8: ` at App (http://host/src/probe.jsx:23:26)` — and the same without a\n * name for top-level code, which is why the parenthesised half is optional.\n *\n * `async`, `new` and `Object.<anonymous>` all land in the name, which is fine:\n * nothing here reads the name, and a name that is slightly wrong costs nothing\n * where a *position* that is slightly wrong costs a reviewer an open file.\n */\nconst V8_FRAME = /^\\s*at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?$/;\n\n/** SpiderMonkey and JavaScriptCore: `App@http://host/src/probe.jsx:23:26`. */\nconst AT_FRAME = /^\\s*(?:(.*?)@)(.+?):(\\d+):(\\d+)$/;\n\n/**\n * The frames of a stack, in order, outermost call last.\n *\n * The message line is dropped, and so is any line neither engine format\n * recognises — a stack is diagnostic output rather than a data format, and a\n * frame that cannot be read is one fewer candidate rather than a failure.\n *\n * `eval` frames are deliberately not unwrapped. V8 writes them as\n * `at eval (eval at fn (http://host/a.js:1:1), <anonymous>:2:3)`, where two\n * positions are present and the inner one is meaningless outside the eval.\n * Taking the outer one would name the line that *called* `eval` as the line that\n * wrote the element, which is a confident wrong answer.\n */\nexport function parseStackFrames(stack: string): StackFrame[] {\n const frames: StackFrame[] = [];\n\n for (const raw of stack.split('\\n')) {\n const line = raw.trimEnd();\n if (line === '' || line.includes('(eval at ')) continue;\n\n const match = V8_FRAME.exec(line) ?? AT_FRAME.exec(line);\n if (match === null) continue;\n\n const [, name, url = '', lineDigits = '', columnDigits = ''] = match;\n if (url === '' || url.startsWith('data:')) continue;\n\n const at = Number(lineDigits);\n const column = Number(columnDigits);\n if (!Number.isFinite(at) || !Number.isFinite(column) || at < 1) continue;\n\n frames.push({\n url,\n line: at,\n column,\n ...(name !== undefined && name !== '' ? { function: name } : {}),\n });\n }\n\n return frames;\n}\n\n/**\n * Whether a path belongs to a dependency rather than to the project.\n *\n * Three spellings of the same fact, because three build tools spell it\n * differently: the directory itself, Vite's prebundled dependency cache, and the\n * `webpack-internal:` scheme wrapping the same directory. The test runs against\n * both the served URL — which saves fetching a map for a megabyte of vendor\n * code — and against the path the map resolves to, which is what actually\n * decides it.\n *\n * A dependency that *is* the subject cannot be attributed by this, and that is\n * the intended reading: a project reviewing its own components wants the line in\n * its own repository, and the line inside `react-dom` that rendered it is never\n * the answer to \"what changed\".\n */\nexport function isVendorPath(path: string): boolean {\n return (\n path.includes('node_modules') ||\n path.includes('/.vite/deps/') ||\n path.includes('/.yarn/') ||\n path.startsWith('webpack-internal:')\n );\n}\n\n/**\n * The location that wrote this element, chosen from its stack.\n *\n * The rule is one sentence: **the first frame that resolves to a file the\n * project wrote.** Frame zero is always React's own runtime, because React is\n * what constructed the error. Frame one is the author in an ordinary build and\n * is a custom JSX runtime in a build that has one — and this does not need to\n * know which, because Emotion resolves into `node_modules` and the component\n * does not.\n *\n * That is the same answer the recorded-symbol path gives, arrived at from the\n * other side. Where `jsx-source` is installed it wins, because it is exact\n * without a map; this is what a project that installed nothing still gets.\n *\n * `originalFor` is supplied rather than performed because resolving a frame\n * means fetching the module the browser was served, and this package may not\n * assume a network (ADR-0013). The caller owns the fetch, the cache, and the\n * decision about how long to wait; this owns which frame to ask about and when\n * to stop asking.\n */\nexport function writerLocationOf(\n frames: readonly StackFrame[],\n originalFor: (frame: StackFrame) => OriginalPosition | null,\n): SourceLocation | null {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n\n const original = originalFor(frame);\n\n // No map is not the same as a vendor frame. An unmapped application module\n // is one a build served as written — which a dev server does for plain `.js`\n // — and its own coordinates are already the answer.\n if (original === null) {\n return { file: frame.url, line: frame.line, column: frame.column };\n }\n\n if (isVendorPath(original.source)) continue;\n\n return { file: original.source, line: original.line, column: original.column };\n }\n\n return null;\n}\n"]}
1
+ {"version":3,"file":"stack.js","sourceRoot":"","sources":["../../src/attribute/stack.ts"],"names":[],"mappings":"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","sourcesContent":["import type { SourceLocation, StackFrame } from '../format/provenance.js';\nimport type { OriginalPosition } from './source-map.js';\n\n/**\n * Reading a call site out of a stack, and deciding which frame is the author.\n *\n * React 19 constructs an `Error` inside its own `jsx`/`jsxDEV` and keeps it on\n * every fiber. That error is a complete answer to \"which line wrote this\n * element\" that no build has to opt into: the runtime that captured it is frame\n * zero, and whoever called it is frame one. Nothing is configured, nothing is\n * installed, and the location is present in any build where React is the\n * development build — which is every dev server, every Vitest run and every Jest\n * run, because all three are what \"not production\" means.\n *\n * Two jobs live here and they are separable on purpose. Parsing a stack is a\n * fact about a JavaScript engine. Choosing *which* frame is the author is a\n * policy, and it is the part with a defensible rule rather than a heuristic:\n * the author is the first frame that maps back to code the project wrote. A\n * custom JSX runtime — Emotion, theme-ui — sits between React and the author and\n * is skipped not because it is on a list but because it resolves into\n * `node_modules`.\n */\n\n/**\n * A frame is a wire value, not a local one: the page reads it, the collector\n * resolves it, and it travels between them on `Provenance`. It is declared where\n * the rest of provenance is declared, and re-exported here because this is the\n * group that does something with it.\n */\nexport type { StackFrame };\n\n/**\n * V8: ` at App (http://host/src/probe.jsx:23:26)` — and the same without a\n * name for top-level code, which is why the parenthesised half is optional.\n *\n * `async`, `new` and `Object.<anonymous>` all land in the name, which is fine:\n * nothing here reads the name, and a name that is slightly wrong costs nothing\n * where a *position* that is slightly wrong costs a reviewer an open file.\n */\nconst V8_FRAME = /^\\s*at\\s+(?:(.+?)\\s+\\()?(.+?):(\\d+):(\\d+)\\)?$/;\n\n/** SpiderMonkey and JavaScriptCore: `App@http://host/src/probe.jsx:23:26`. */\nconst AT_FRAME = /^\\s*(?:(.*?)@)(.+?):(\\d+):(\\d+)$/;\n\n/**\n * The frames of a stack, in order, outermost call last.\n *\n * The message line is dropped, and so is any line neither engine format\n * recognises — a stack is diagnostic output rather than a data format, and a\n * frame that cannot be read is one fewer candidate rather than a failure.\n *\n * `eval` frames are deliberately not unwrapped. V8 writes them as\n * `at eval (eval at fn (http://host/a.js:1:1), <anonymous>:2:3)`, where two\n * positions are present and the inner one is meaningless outside the eval.\n * Taking the outer one would name the line that *called* `eval` as the line that\n * wrote the element, which is a confident wrong answer.\n */\nexport function parseStackFrames(stack: string): StackFrame[] {\n const frames: StackFrame[] = [];\n\n for (const raw of stack.split('\\n')) {\n const line = raw.trimEnd();\n if (line === '' || line.includes('(eval at ')) continue;\n\n const match = V8_FRAME.exec(line) ?? AT_FRAME.exec(line);\n if (match === null) continue;\n\n const [, name, url = '', lineDigits = '', columnDigits = ''] = match;\n if (url === '' || url.startsWith('data:')) continue;\n\n const at = Number(lineDigits);\n const column = Number(columnDigits);\n if (!Number.isFinite(at) || !Number.isFinite(column) || at < 1) continue;\n\n frames.push({\n url,\n line: at,\n column,\n ...(name !== undefined && name !== '' ? { function: name } : {}),\n });\n }\n\n return frames;\n}\n\n/**\n * Whether a path belongs to a dependency rather than to the project.\n *\n * Three spellings of the same fact, because three build tools spell it\n * differently: the directory itself, Vite's prebundled dependency cache, and the\n * `webpack-internal:` scheme wrapping the same directory. The test runs against\n * both the served URL — which saves fetching a map for a megabyte of vendor\n * code — and against the path the map resolves to, which is what actually\n * decides it.\n *\n * A dependency that *is* the subject cannot be attributed by this, and that is\n * the intended reading: a project reviewing its own components wants the line in\n * its own repository, and the line inside `react-dom` that rendered it is never\n * the answer to \"what changed\".\n */\nexport function isVendorPath(path: string): boolean {\n return (\n path.includes('node_modules') ||\n path.includes('/.vite/deps/') ||\n path.includes('/.yarn/') ||\n path.startsWith('webpack-internal:')\n );\n}\n\n/**\n * A served URL as a path: the origin dropped, the rest kept.\n *\n * An origin is a fact about the machine that ran the capture rather than about\n * the code. A dev server binds an ephemeral port and a preview server binds\n * another, so two runs of one suite would name every location differently, and\n * a cache-busting query would move under the same hand. What is left is what\n * the source index and `relativizeSource` already speak.\n *\n * Anything that does not parse as a URL is left exactly as it is — a bundler\n * that wrote an absolute filesystem path, or a `webpack://` specifier — because\n * inventing a shape for those would be a guess.\n */\nexport function servedPath(url: string): string {\n if (URL_OF === undefined) return url;\n try {\n return new URL_OF(url).pathname.replace(/^\\/+/, '');\n } catch {\n return url;\n }\n}\n\n/**\n * The frame's own position on a disk, or `undefined` if a server sent it.\n *\n * The two arrive in the same field and mean different things when no map\n * answers for them. A browser frame is a position in whatever text the build\n * served, and only a map can say where that text came from. A Node frame is\n * already a position in a file: Node applies maps to `Error.stack` itself, so a\n * Vitest or Jest frame has been resolved before anything here sees it.\n *\n * Decided by the frame's own shape, because that is a property of the value in\n * hand. Nothing here asks a filesystem whether a path is real; a coordinate is\n * ours because of how it was obtained, and a disk cannot be asked about how.\n *\n * Four shapes are a disk. A bare path does not parse as a URL at all. A\n * `file:` URL is a path spelled formally, so it is decoded back into one —\n * whole, including the leading separator a served path sheds, because an\n * absolute path that arrives looking repository-relative is a wrong answer that\n * resolves. A Windows path is checked before either, because `C:\\src\\probe.ts`\n * parses as a URL whose scheme is the drive letter: a test that asked only\n * whether a frame parsed would call every frame on Windows served, and attribute\n * nothing at all there.\n *\n * **The fourth shape is React's, and it is refused — measured, not assumed.**\n * A component that ran on the server has no fiber in the browser and no module\n * the browser was served, so React manufactures a frame on the client out of\n * coordinates the server put on the wire:\n * `about://React/<environment>/<url>?<n>`. It *looks* like the third shape — a\n * `file:` URL naming a file in the repository — and it is not one. The\n * coordinates inside it are positions in the module the **server** compiled,\n * and that module is not the one the browser can fetch.\n *\n * Reading it as a path was tried against a real RSC route, and the answer was\n * wrong in the way that is worst: plausibly. React's frame for one `<aside>`\n * read `DocsPage.tsx:200`, in a file 146 lines long; the browser's own build of\n * the same source put that element at line 196 of a 268-line module, and\n * resolving *that* through its map landed on line 126 — a different element,\n * four lines from the right answer, with nothing about it to say so.\n *\n * So it falls to the `file:` test below and yields nothing, which is the rule\n * {@link writerLocationOf} already states for every served frame: a position in\n * text the build made is a position in this repository only if a map says so.\n * The component is still named — that comes from the owner chain, not from\n * here — and the source index still answers where it was declared.\n */\nexport function writtenPath(url: string): string | undefined {\n if (DRIVE.test(url)) return url;\n if (URL_OF === undefined) return url;\n\n let parsed;\n try {\n parsed = new URL_OF(url);\n } catch {\n return url;\n }\n\n if (parsed.protocol !== 'file:') return undefined;\n const path = decoded(parsed.pathname);\n return DRIVE.test(path.slice(1)) ? path.slice(1) : path;\n}\n\n/** `C:\\src\\probe.ts` and `C:/src/probe.ts`, and inside a `file:` URL's path. */\nconst DRIVE = /^[A-Za-z]:[\\\\/]/;\n\nfunction decoded(pathname: string): string {\n try {\n return decodeURIComponent(pathname);\n } catch {\n return pathname;\n }\n}\n\n/**\n * The one host global this file needs, asked for rather than assumed. `core`\n * types no host library (ADR-0001), and taking a URL apart by hand is a bug\n * farm.\n */\nconst URL_OF = (globalThis as { URL?: new (url: string) => ParsedUrl }).URL;\n\n/** As much of a parsed URL as this file reads. See {@link URL_OF}. */\ninterface ParsedUrl {\n readonly pathname: string;\n readonly protocol: string;\n}\n\n/**\n * The location that wrote this element, chosen from its stack.\n *\n * The rule is one sentence: **the first frame that resolves to a file the\n * project wrote.** Frame zero is always React's own runtime, because React is\n * what constructed the error. Frame one is the author in an ordinary build and\n * is a custom JSX runtime in a build that has one — and this does not need to\n * know which, because Emotion resolves into `node_modules` and the component\n * does not.\n *\n * That is the same answer the recorded-symbol path gives, arrived at from the\n * other side. Where `jsx-source` is installed it wins, because it is exact\n * without a map; this is what a project that installed nothing still gets — as\n * long as the build serving those frames emits maps. Where it does not, this\n * answers `null`, and a caller with no location is a caller that says so.\n *\n * `originalFor` is supplied rather than performed because resolving a frame\n * means fetching the module the browser was served, and this package may not\n * assume a network (ADR-0013). The caller owns the fetch, the cache, and the\n * decision about how long to wait; this owns which frame to ask about and when\n * to stop asking.\n */\nexport function writerLocationOf(\n frames: readonly StackFrame[],\n originalFor: (frame: StackFrame) => OriginalPosition | null,\n): SourceLocation | null {\n for (const frame of frames) {\n if (isVendorPath(frame.url)) continue;\n\n const original = originalFor(frame);\n\n // A served frame with no map is a position in text the build made, and only\n // a map can say where that text came from. It is one fewer candidate rather\n // than a location: naming it anyway would put a coordinate in this\n // repository's basis that nothing in this repository was measured in, which\n // reads exactly like a real one and sends a reviewer to a line at random.\n //\n // A frame that is a path needs no map to have been read. Node applies them\n // to `Error.stack` before a runner ever sees it, so the position arrived\n // already resolved and the only thing left to take off it is the origin.\n if (original === null) {\n const written = writtenPath(frame.url);\n if (written === undefined) continue;\n return { file: written, line: frame.line, column: frame.column };\n }\n\n if (isVendorPath(original.source)) continue;\n\n return { file: original.source, line: original.line, column: original.column };\n }\n\n return null;\n}\n"]}
@@ -2,6 +2,7 @@ import { type Digest } from '../format/hash.js';
2
2
  import type { SemanticSnapshot } from '../format/snapshot.js';
3
3
  import type { EnvironmentDelta } from '../format/environment.js';
4
4
  import { type Band } from './band.js';
5
+ import type { Observability } from './observability.js';
5
6
  import type { AggregateImpact } from './impact.js';
6
7
  import { type ChangedComponent, type Delta, type Root } from './diff/index.js';
7
8
  /**
@@ -41,6 +42,22 @@ export interface Variation {
41
42
  * to refuse (ADR-0002).
42
43
  */
43
44
  readonly unobserved: readonly Band[];
45
+ /**
46
+ * How completely each band was decided, weaker side winning.
47
+ *
48
+ * A variation is the one comparison that crosses profiles on purpose, so this
49
+ * is where the weaker-side rule has work to do: a dark story read in Chromium
50
+ * against a light parent read in JSDOM is only as well observed as the JSDOM
51
+ * side, band by band.
52
+ */
53
+ readonly observability: Readonly<Record<Band, Observability>>;
54
+ /**
55
+ * Bands decided on less than the evidence the band is made of.
56
+ *
57
+ * The pair is alike *there* in a narrower sense than elsewhere, and a report
58
+ * that prints one word for both senses is the difference nobody can see.
59
+ */
60
+ readonly narrowed: readonly Band[];
44
61
  /**
45
62
  * Render inputs that differ between the two sides.
46
63
  *
@@ -1 +1 @@
1
- {"version":3,"file":"derive.js","sourceRoot":"","sources":["../../src/compare/derive.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,WAAW,EAAe,MAAM,mBAAmB,CAAC;AAG5E,OAAO,EAAE,KAAK,EAAa,MAAM,WAAW,CAAC;AAE7C,OAAO,EAAE,YAAY,EAAgD,MAAM,iBAAiB,CAAC;AAuE7F;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAAwB,EAAE,OAAyB;IACjF,MAAM,QAAQ,GAAG,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAElE,OAAO;QACL,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,EAAE;QAC3B,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE;QACzB,GAAG,QAAQ;QACX,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC9C,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,MAAM,CAAC;KAC5C,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,kBAAkB,CAAC,MAAwB;IAClD,OAAO,aAAa,CAClB,cAAc,EACd,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,WAAW,CAAC;QACV,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,EAAE,EAAE,KAAK,CAAC,EAAE;KACb,CAAC,CACH,CACF,CAAC;AACJ,CAAC","sourcesContent":["import { digestCombine, digestValue, type Digest } from '../format/hash.js';\nimport type { SemanticSnapshot } from '../format/snapshot.js';\nimport type { EnvironmentDelta } from '../format/environment.js';\nimport { BANDS, type Band } from './band.js';\nimport type { AggregateImpact } from './impact.js';\nimport { compareTrees, type ChangedComponent, type Delta, type Root } from './diff/index.js';\n\n/**\n * One subject read as a variation of another.\n *\n * The same arithmetic a verdict is made of, pointed at a pair of subjects\n * somebody linked on purpose: a dark story against its light parent, a mobile\n * viewport against the desktop one, a story behind a feature flag against the\n * story it was copied from. Nothing here is compared against a baseline and\n * nothing here is a verdict — a variation *is* a difference, so reporting it as a\n * regression would be reporting the subject for existing.\n *\n * What it answers is the question a second case cannot: **a new story under a\n * flag is a new baseline and an empty diff, and everybody has to open two\n * pictures to see what the flag does.** Linked, the difference is a value with an\n * identity, and the identity is the useful part — see {@link Variation.digest}.\n */\nexport interface Variation {\n /** The variation's own subject id. */\n readonly subject: string;\n /** The subject it declares itself a variation of. */\n readonly parent: string;\n\n /** `true` when the two renders hash identically — the flag changed nothing. */\n readonly identical: boolean;\n\n readonly deltas: readonly Delta[];\n readonly roots: readonly Root[];\n readonly components: readonly ChangedComponent[];\n readonly impact: AggregateImpact;\n\n /** Bands the difference falls in, in band order. Empty when identical. */\n readonly bands: readonly Band[];\n\n /**\n * Bands **either** side's profile could not decide.\n *\n * Union rather than intersection, and never dropped: a jsdom side has no\n * geometry, and a variation that reported no geometry difference because\n * nothing could measure one would be the false `unchanged` this project exists\n * to refuse (ADR-0002).\n */\n readonly unobserved: readonly Band[];\n\n /**\n * Render inputs that differ between the two sides.\n *\n * Reported, never a refusal. A variation is frequently a *deliberate*\n * environment difference — a viewport, a colour scheme — and the field that\n * would make `diffSnapshots` refuse the pair is here the thing being examined.\n */\n readonly environmentDeltas: readonly EnvironmentDelta[];\n\n /**\n * The identity of the difference itself: `variation/v1` over the deltas.\n *\n * The field that makes a linked pair worth more than two pictures. A parent and\n * its variation both move by the same edit — a token changed, both re-render,\n * both go red — and this digest does not move, because what the variation *adds*\n * to its parent is unchanged. So the reviewer's question stops being \"did these\n * two subjects change\" and becomes \"did what this flag does change\", which is\n * the question they were trying to ask.\n *\n * Positions and values are in it and owner chains are not. An owner frame\n * carries a props digest per frame, so hashing it would make the identity move\n * whenever any component above the change was handed a different object —\n * which is a fact about the render, not about the difference.\n */\n readonly digest: Digest;\n}\n\n/**\n * Compare a variation against the subject it derives from.\n *\n * Refuses nothing. Two subject ids are the input this function exists for, and\n * two profiles or two environments are reported as part of the answer — see\n * `compareTrees` for why the refusals live with the caller that owes them rather\n * than with the arithmetic.\n */\nexport function deriveVariation(parent: SemanticSnapshot, variant: SemanticSnapshot): Variation {\n const compared = compareTrees(parent, variant);\n const moved = new Set(compared.deltas.map((delta) => delta.band));\n\n return {\n subject: variant.subject.id,\n parent: parent.subject.id,\n ...compared,\n bands: BANDS.filter((band) => moved.has(band)),\n digest: digestOfDifference(compared.deltas),\n };\n}\n\n/**\n * `variation/v1` over what each delta *is*, in the order the comparison produced.\n *\n * Order is the comparison's, which is a function of the two trees and of nothing\n * else — sorting here would cost a walk to buy stability the input already has.\n */\nfunction digestOfDifference(deltas: readonly Delta[]): Digest {\n return digestCombine(\n 'variation/v1',\n deltas.map((delta) =>\n digestValue({\n kind: delta.kind,\n band: delta.band,\n path: delta.path,\n property: delta.property,\n from: delta.from,\n to: delta.to,\n }),\n ),\n );\n}\n"]}
1
+ {"version":3,"file":"derive.js","sourceRoot":"","sources":["../../src/compare/derive.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,WAAW,EAAe,MAAM,mBAAmB,CAAC;AAG5E,OAAO,EAAE,KAAK,EAAa,MAAM,WAAW,CAAC;AAG7C,OAAO,EAAE,YAAY,EAAgD,MAAM,iBAAiB,CAAC;AAyF7F;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAAwB,EAAE,OAAyB;IACjF,MAAM,QAAQ,GAAG,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAElE,OAAO;QACL,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,EAAE;QAC3B,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,EAAE;QACzB,GAAG,QAAQ;QACX,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC9C,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,MAAM,CAAC;KAC5C,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,kBAAkB,CAAC,MAAwB;IAClD,OAAO,aAAa,CAClB,cAAc,EACd,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,WAAW,CAAC;QACV,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,EAAE,EAAE,KAAK,CAAC,EAAE;KACb,CAAC,CACH,CACF,CAAC;AACJ,CAAC","sourcesContent":["import { digestCombine, digestValue, type Digest } from '../format/hash.js';\nimport type { SemanticSnapshot } from '../format/snapshot.js';\nimport type { EnvironmentDelta } from '../format/environment.js';\nimport { BANDS, type Band } from './band.js';\nimport type { Observability } from './observability.js';\nimport type { AggregateImpact } from './impact.js';\nimport { compareTrees, type ChangedComponent, type Delta, type Root } from './diff/index.js';\n\n/**\n * One subject read as a variation of another.\n *\n * The same arithmetic a verdict is made of, pointed at a pair of subjects\n * somebody linked on purpose: a dark story against its light parent, a mobile\n * viewport against the desktop one, a story behind a feature flag against the\n * story it was copied from. Nothing here is compared against a baseline and\n * nothing here is a verdict — a variation *is* a difference, so reporting it as a\n * regression would be reporting the subject for existing.\n *\n * What it answers is the question a second case cannot: **a new story under a\n * flag is a new baseline and an empty diff, and everybody has to open two\n * pictures to see what the flag does.** Linked, the difference is a value with an\n * identity, and the identity is the useful part — see {@link Variation.digest}.\n */\nexport interface Variation {\n /** The variation's own subject id. */\n readonly subject: string;\n /** The subject it declares itself a variation of. */\n readonly parent: string;\n\n /** `true` when the two renders hash identically — the flag changed nothing. */\n readonly identical: boolean;\n\n readonly deltas: readonly Delta[];\n readonly roots: readonly Root[];\n readonly components: readonly ChangedComponent[];\n readonly impact: AggregateImpact;\n\n /** Bands the difference falls in, in band order. Empty when identical. */\n readonly bands: readonly Band[];\n\n /**\n * Bands **either** side's profile could not decide.\n *\n * Union rather than intersection, and never dropped: a jsdom side has no\n * geometry, and a variation that reported no geometry difference because\n * nothing could measure one would be the false `unchanged` this project exists\n * to refuse (ADR-0002).\n */\n readonly unobserved: readonly Band[];\n\n /**\n * How completely each band was decided, weaker side winning.\n *\n * A variation is the one comparison that crosses profiles on purpose, so this\n * is where the weaker-side rule has work to do: a dark story read in Chromium\n * against a light parent read in JSDOM is only as well observed as the JSDOM\n * side, band by band.\n */\n readonly observability: Readonly<Record<Band, Observability>>;\n\n /**\n * Bands decided on less than the evidence the band is made of.\n *\n * The pair is alike *there* in a narrower sense than elsewhere, and a report\n * that prints one word for both senses is the difference nobody can see.\n */\n readonly narrowed: readonly Band[];\n\n /**\n * Render inputs that differ between the two sides.\n *\n * Reported, never a refusal. A variation is frequently a *deliberate*\n * environment difference — a viewport, a colour scheme — and the field that\n * would make `diffSnapshots` refuse the pair is here the thing being examined.\n */\n readonly environmentDeltas: readonly EnvironmentDelta[];\n\n /**\n * The identity of the difference itself: `variation/v1` over the deltas.\n *\n * The field that makes a linked pair worth more than two pictures. A parent and\n * its variation both move by the same edit — a token changed, both re-render,\n * both go red — and this digest does not move, because what the variation *adds*\n * to its parent is unchanged. So the reviewer's question stops being \"did these\n * two subjects change\" and becomes \"did what this flag does change\", which is\n * the question they were trying to ask.\n *\n * Positions and values are in it and owner chains are not. An owner frame\n * carries a props digest per frame, so hashing it would make the identity move\n * whenever any component above the change was handed a different object —\n * which is a fact about the render, not about the difference.\n */\n readonly digest: Digest;\n}\n\n/**\n * Compare a variation against the subject it derives from.\n *\n * Refuses nothing. Two subject ids are the input this function exists for, and\n * two profiles or two environments are reported as part of the answer — see\n * `compareTrees` for why the refusals live with the caller that owes them rather\n * than with the arithmetic.\n */\nexport function deriveVariation(parent: SemanticSnapshot, variant: SemanticSnapshot): Variation {\n const compared = compareTrees(parent, variant);\n const moved = new Set(compared.deltas.map((delta) => delta.band));\n\n return {\n subject: variant.subject.id,\n parent: parent.subject.id,\n ...compared,\n bands: BANDS.filter((band) => moved.has(band)),\n digest: digestOfDifference(compared.deltas),\n };\n}\n\n/**\n * `variation/v1` over what each delta *is*, in the order the comparison produced.\n *\n * Order is the comparison's, which is a function of the two trees and of nothing\n * else — sorting here would cost a walk to buy stability the input already has.\n */\nfunction digestOfDifference(deltas: readonly Delta[]): Digest {\n return digestCombine(\n 'variation/v1',\n deltas.map((delta) =>\n digestValue({\n kind: delta.kind,\n band: delta.band,\n path: delta.path,\n property: delta.property,\n from: delta.from,\n to: delta.to,\n }),\n ),\n );\n}\n"]}
@@ -1,7 +1,8 @@
1
- import { type Band } from '../band.js';
1
+ import type { Band } from '../band.js';
2
2
  import { type AggregateImpact } from '../impact.js';
3
3
  import { type EnvironmentDelta } from '../../format/environment.js';
4
4
  import type { SemanticSnapshot } from '../../format/snapshot.js';
5
+ import { type Observability } from '../observability.js';
5
6
  import { type ChangedComponent, type Delta, type Root } from './delta.js';
6
7
  export type { ChangedComponent, Delta, Root, RootKind } from './delta.js';
7
8
  export { matchTrees } from './match.js';
@@ -30,6 +31,16 @@ export interface SemanticDiff {
30
31
  * failing to observe it.
31
32
  */
32
33
  readonly impact: AggregateImpact;
34
+ /**
35
+ * How completely each band was decided, weaker side winning.
36
+ *
37
+ * The primitive the two lists below are views of. Carried rather than reduced
38
+ * to them on the way out, because `unchanged` is one word for two readings of
39
+ * different strength and a caller with a stricter policy than ours has to be
40
+ * able to tell them apart without re-deriving the profile arithmetic — which
41
+ * is the version of this that drifts.
42
+ */
43
+ readonly observability: Readonly<Record<Band, Observability>>;
33
44
  /**
34
45
  * Bands this profile could not observe.
35
46
  *
@@ -37,6 +48,19 @@ export interface SemanticDiff {
37
48
  * fine" under a profile with no layout engine (ADR-0002).
38
49
  */
39
50
  readonly unobserved: readonly Band[];
51
+ /**
52
+ * Bands decided on less than the evidence the band is made of.
53
+ *
54
+ * `unobserved`'s quiet half. A band here returned an answer, and the answer is
55
+ * narrower than the word carrying it: a `token` band decided `declared-only`
56
+ * compared what an author wrote and not what an engine resolved, so
57
+ * `padding: 1rem` is `1rem` on both sides and a root font-size that moved
58
+ * underneath it is outside the comparison. Absent from the unobserved list on
59
+ * purpose — the band *was* decided — and named here so the qualification
60
+ * reaches whoever reads the verdict instead of stopping at the profile that
61
+ * produced it.
62
+ */
63
+ readonly narrowed: readonly Band[];
40
64
  }
41
65
  /**
42
66
  * Compare two snapshots of one subject.
@@ -1,7 +1,6 @@
1
- import { BANDS } from '../band.js';
2
1
  import { aggregateImpact } from '../impact.js';
3
2
  import { diffEnvironments } from '../../format/environment.js';
4
- import { decidesBand, observableBands } from '../observability.js';
3
+ import { narrowedBands, sharedObservability, unobservedBands, } from '../observability.js';
5
4
  import { impactTag } from './delta.js';
6
5
  import { matchTrees } from './match.js';
7
6
  import { compareNodes, wholeNode } from './compare-nodes.js';
@@ -43,9 +42,9 @@ export function diffSnapshots(baseline, candidate) {
43
42
  * costs it nothing and changes none of its answers.
44
43
  */
45
44
  export function compareTrees(baseline, candidate) {
46
- const here = observableBands(candidate.profile);
47
- const there = observableBands(baseline.profile);
48
- const unobserved = BANDS.filter((band) => !decidesBand(band, here[band]) || !decidesBand(band, there[band]));
45
+ const observability = sharedObservability(candidate.profile, baseline.profile);
46
+ const unobserved = unobservedBands(observability);
47
+ const narrowed = narrowedBands(observability);
49
48
  const environmentDeltas = diffEnvironments(baseline.environment.inputs, candidate.environment.inputs);
50
49
  if (baseline.renderHash === candidate.renderHash) {
51
50
  return {
@@ -55,7 +54,9 @@ export function compareTrees(baseline, candidate) {
55
54
  roots: [],
56
55
  components: [],
57
56
  impact: 'paint',
57
+ observability,
58
58
  unobserved,
59
+ narrowed,
59
60
  };
60
61
  }
61
62
  const matching = matchTrees(baseline.root, candidate.root);
@@ -95,7 +96,9 @@ export function compareTrees(baseline, candidate) {
95
96
  roots,
96
97
  components: componentsOf(located, roots),
97
98
  impact: aggregateImpact(located.map(impactTag)),
99
+ observability,
98
100
  unobserved,
101
+ narrowed,
99
102
  };
100
103
  }
101
104
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/compare/diff/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { BANDS, type Band } from '../band.js';\nimport { aggregateImpact, type AggregateImpact } from '../impact.js';\nimport { diffEnvironments, type EnvironmentDelta } from '../../format/environment.js';\nimport type { SemanticSnapshot } from '../../format/snapshot.js';\nimport { decidesBand, observableBands } from '../observability.js';\nimport { impactTag, type ChangedComponent, type Delta, type Root } from './delta.js';\nimport { matchTrees } from './match.js';\nimport { compareNodes, wholeNode } from './compare-nodes.js';\nimport { attribute } from './attribution.js';\nimport { componentsOf } from './components.js';\nimport { locate } from '../../attribute/locate.js';\n\nexport type { ChangedComponent, Delta, Root, RootKind } from './delta.js';\nexport { matchTrees } from './match.js';\nexport type { Matching } from './match.js';\n\nexport interface SemanticDiff {\n readonly subjectId: string;\n /** `true` when both render hashes agree — nothing below need be consulted. */\n readonly identical: boolean;\n\n readonly environmentDeltas: readonly EnvironmentDelta[];\n readonly deltas: readonly Delta[];\n\n /** One entry per explanation. This is what a docket renders and approves. */\n readonly roots: readonly Root[];\n\n /**\n * Components implicated, separated into causes and collateral.\n *\n * \"Eleven components changed\" reads like eleven problems. \"`Button` changed,\n * and ten components render it\" reads like one, which is what it is.\n */\n readonly components: readonly ChangedComponent[];\n\n /**\n * Whether anything in this change set can move a box.\n *\n * `paint` or `composite` means nothing reflowed and no geometric collateral is\n * possible — a conclusion available without a layout engine, which is how a\n * profile that cannot measure still rules movement out instead of merely\n * failing to observe it.\n */\n readonly impact: AggregateImpact;\n\n /**\n * Bands this profile could not observe.\n *\n * Reported so that \"no geometry deltas\" is never mistaken for \"geometry is\n * fine\" under a profile with no layout engine (ADR-0002).\n */\n readonly unobserved: readonly Band[];\n}\n\n/**\n * Compare two snapshots of one subject.\n *\n * @throws {Error} when the snapshots come from different subjects or different\n * observation profiles. Neither is a large diff — they are a category error, and\n * returning deltas for them would let a JSDOM run appear to satisfy a Chromium\n * baseline while blind to every geometry change in it.\n */\nexport function diffSnapshots(\n baseline: SemanticSnapshot,\n candidate: SemanticSnapshot,\n): SemanticDiff {\n if (baseline.subject.id !== candidate.subject.id) {\n throw new Error(\n `refusing to diff different subjects: ${baseline.subject.id} vs ${candidate.subject.id}`,\n );\n }\n\n if (baseline.profile.id !== candidate.profile.id) {\n throw new Error(\n `refusing to diff across observation profiles: ${baseline.profile.id} vs ${candidate.profile.id}`,\n );\n }\n\n return { subjectId: candidate.subject.id, ...compareTrees(baseline, candidate) };\n}\n\n/** Everything a {@link SemanticDiff} is except the subject it is about. */\nexport type TreeComparison = Omit<SemanticDiff, 'subjectId'>;\n\n/**\n * The comparison itself, after whatever refusals the caller owes.\n *\n * Split out because two callers owe different refusals over the same arithmetic.\n * `diffSnapshots` produces a **verdict** and must refuse two subjects, because a\n * verdict that crossed them would let one subject's baseline stand in for\n * another's. `deriveVariation` produces an **explanation** of a difference\n * somebody declared on purpose — a dark story against its light parent — where\n * two subject ids are the entire point and a refusal would be the bug.\n *\n * What is not negotiable either way is the observability rule: a band is\n * `unobserved` unless **both** sides could decide it, so a difference that only\n * one profile could have seen is never reported as no difference (ADR-0002).\n * Under `diffSnapshots` the two profiles are already identical, so the union\n * costs it nothing and changes none of its answers.\n */\nexport function compareTrees(\n baseline: SemanticSnapshot,\n candidate: SemanticSnapshot,\n): TreeComparison {\n const here = observableBands(candidate.profile);\n const there = observableBands(baseline.profile);\n const unobserved: Band[] = BANDS.filter(\n (band) => !decidesBand(band, here[band]) || !decidesBand(band, there[band]),\n );\n\n const environmentDeltas = diffEnvironments(\n baseline.environment.inputs,\n candidate.environment.inputs,\n );\n\n if (baseline.renderHash === candidate.renderHash) {\n return {\n identical: true,\n environmentDeltas,\n deltas: [],\n roots: [],\n components: [],\n impact: 'paint',\n unobserved,\n };\n }\n\n const matching = matchTrees(baseline.root, candidate.root);\n const deltas: Delta[] = [];\n\n for (const node of matching.added) {\n deltas.push(wholeNode('node-added', node));\n }\n for (const node of matching.removed) {\n deltas.push(wholeNode('node-removed', node));\n }\n\n for (const [before, after] of matching.pairs) {\n if (matching.moved.has(before)) {\n deltas.push(wholeNode('node-moved', after));\n }\n compareNodes(before, after, deltas, candidate.profile.layout && baseline.profile.layout);\n }\n\n // Orientation is attached after the deltas exist, in one pass over the\n // candidate tree. Doing it during comparison would locate against whichever\n // tree that comparison happened to be holding, and a removal must be located\n // in the tree it was removed *from* — see below.\n const located = deltas.map((delta) => {\n const tree = delta.kind === 'node-removed' ? baseline.root : candidate.root;\n const where = locate(tree, delta.path);\n\n return where.where === ''\n ? delta\n : {\n ...delta,\n where: where.where,\n ...(where.region !== undefined ? { region: where.region } : {}),\n };\n });\n\n const roots = attribute(located, matching, environmentDeltas);\n\n return {\n identical: false,\n environmentDeltas,\n deltas: located,\n roots,\n components: componentsOf(located, roots),\n impact: aggregateImpact(located.map(impactTag)),\n unobserved,\n };\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/compare/diff/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Band } from '../band.js';\nimport { aggregateImpact, type AggregateImpact } from '../impact.js';\nimport { diffEnvironments, type EnvironmentDelta } from '../../format/environment.js';\nimport type { SemanticSnapshot } from '../../format/snapshot.js';\nimport {\n narrowedBands,\n sharedObservability,\n unobservedBands,\n type Observability,\n} from '../observability.js';\nimport { impactTag, type ChangedComponent, type Delta, type Root } from './delta.js';\nimport { matchTrees } from './match.js';\nimport { compareNodes, wholeNode } from './compare-nodes.js';\nimport { attribute } from './attribution.js';\nimport { componentsOf } from './components.js';\nimport { locate } from '../../attribute/locate.js';\n\nexport type { ChangedComponent, Delta, Root, RootKind } from './delta.js';\nexport { matchTrees } from './match.js';\nexport type { Matching } from './match.js';\n\nexport interface SemanticDiff {\n readonly subjectId: string;\n /** `true` when both render hashes agree — nothing below need be consulted. */\n readonly identical: boolean;\n\n readonly environmentDeltas: readonly EnvironmentDelta[];\n readonly deltas: readonly Delta[];\n\n /** One entry per explanation. This is what a docket renders and approves. */\n readonly roots: readonly Root[];\n\n /**\n * Components implicated, separated into causes and collateral.\n *\n * \"Eleven components changed\" reads like eleven problems. \"`Button` changed,\n * and ten components render it\" reads like one, which is what it is.\n */\n readonly components: readonly ChangedComponent[];\n\n /**\n * Whether anything in this change set can move a box.\n *\n * `paint` or `composite` means nothing reflowed and no geometric collateral is\n * possible — a conclusion available without a layout engine, which is how a\n * profile that cannot measure still rules movement out instead of merely\n * failing to observe it.\n */\n readonly impact: AggregateImpact;\n\n /**\n * How completely each band was decided, weaker side winning.\n *\n * The primitive the two lists below are views of. Carried rather than reduced\n * to them on the way out, because `unchanged` is one word for two readings of\n * different strength and a caller with a stricter policy than ours has to be\n * able to tell them apart without re-deriving the profile arithmetic — which\n * is the version of this that drifts.\n */\n readonly observability: Readonly<Record<Band, Observability>>;\n\n /**\n * Bands this profile could not observe.\n *\n * Reported so that \"no geometry deltas\" is never mistaken for \"geometry is\n * fine\" under a profile with no layout engine (ADR-0002).\n */\n readonly unobserved: readonly Band[];\n\n /**\n * Bands decided on less than the evidence the band is made of.\n *\n * `unobserved`'s quiet half. A band here returned an answer, and the answer is\n * narrower than the word carrying it: a `token` band decided `declared-only`\n * compared what an author wrote and not what an engine resolved, so\n * `padding: 1rem` is `1rem` on both sides and a root font-size that moved\n * underneath it is outside the comparison. Absent from the unobserved list on\n * purpose — the band *was* decided — and named here so the qualification\n * reaches whoever reads the verdict instead of stopping at the profile that\n * produced it.\n */\n readonly narrowed: readonly Band[];\n}\n\n/**\n * Compare two snapshots of one subject.\n *\n * @throws {Error} when the snapshots come from different subjects or different\n * observation profiles. Neither is a large diff — they are a category error, and\n * returning deltas for them would let a JSDOM run appear to satisfy a Chromium\n * baseline while blind to every geometry change in it.\n */\nexport function diffSnapshots(\n baseline: SemanticSnapshot,\n candidate: SemanticSnapshot,\n): SemanticDiff {\n if (baseline.subject.id !== candidate.subject.id) {\n throw new Error(\n `refusing to diff different subjects: ${baseline.subject.id} vs ${candidate.subject.id}`,\n );\n }\n\n if (baseline.profile.id !== candidate.profile.id) {\n throw new Error(\n `refusing to diff across observation profiles: ${baseline.profile.id} vs ${candidate.profile.id}`,\n );\n }\n\n return { subjectId: candidate.subject.id, ...compareTrees(baseline, candidate) };\n}\n\n/** Everything a {@link SemanticDiff} is except the subject it is about. */\nexport type TreeComparison = Omit<SemanticDiff, 'subjectId'>;\n\n/**\n * The comparison itself, after whatever refusals the caller owes.\n *\n * Split out because two callers owe different refusals over the same arithmetic.\n * `diffSnapshots` produces a **verdict** and must refuse two subjects, because a\n * verdict that crossed them would let one subject's baseline stand in for\n * another's. `deriveVariation` produces an **explanation** of a difference\n * somebody declared on purpose — a dark story against its light parent — where\n * two subject ids are the entire point and a refusal would be the bug.\n *\n * What is not negotiable either way is the observability rule: a band is\n * `unobserved` unless **both** sides could decide it, so a difference that only\n * one profile could have seen is never reported as no difference (ADR-0002).\n * Under `diffSnapshots` the two profiles are already identical, so the union\n * costs it nothing and changes none of its answers.\n */\nexport function compareTrees(\n baseline: SemanticSnapshot,\n candidate: SemanticSnapshot,\n): TreeComparison {\n const observability = sharedObservability(candidate.profile, baseline.profile);\n const unobserved = unobservedBands(observability);\n const narrowed = narrowedBands(observability);\n\n const environmentDeltas = diffEnvironments(\n baseline.environment.inputs,\n candidate.environment.inputs,\n );\n\n if (baseline.renderHash === candidate.renderHash) {\n return {\n identical: true,\n environmentDeltas,\n deltas: [],\n roots: [],\n components: [],\n impact: 'paint',\n observability,\n unobserved,\n narrowed,\n };\n }\n\n const matching = matchTrees(baseline.root, candidate.root);\n const deltas: Delta[] = [];\n\n for (const node of matching.added) {\n deltas.push(wholeNode('node-added', node));\n }\n for (const node of matching.removed) {\n deltas.push(wholeNode('node-removed', node));\n }\n\n for (const [before, after] of matching.pairs) {\n if (matching.moved.has(before)) {\n deltas.push(wholeNode('node-moved', after));\n }\n compareNodes(before, after, deltas, candidate.profile.layout && baseline.profile.layout);\n }\n\n // Orientation is attached after the deltas exist, in one pass over the\n // candidate tree. Doing it during comparison would locate against whichever\n // tree that comparison happened to be holding, and a removal must be located\n // in the tree it was removed *from* — see below.\n const located = deltas.map((delta) => {\n const tree = delta.kind === 'node-removed' ? baseline.root : candidate.root;\n const where = locate(tree, delta.path);\n\n return where.where === ''\n ? delta\n : {\n ...delta,\n where: where.where,\n ...(where.region !== undefined ? { region: where.region } : {}),\n };\n });\n\n const roots = attribute(located, matching, environmentDeltas);\n\n return {\n identical: false,\n environmentDeltas,\n deltas: located,\n roots,\n components: componentsOf(located, roots),\n impact: aggregateImpact(located.map(impactTag)),\n observability,\n unobserved,\n narrowed,\n };\n}\n"]}
@@ -8,7 +8,7 @@ import type { Parting } from './parting.js';
8
8
  * ```text
9
9
  * a <div> rendered <p> on one side and <span> on the other
10
10
  * Summary was handed a different `total`
11
- * Cart chose differently — useState #2 moved, and Summary's `total` follows
11
+ * Cart chose differently — useState #2 changed, and Summary's `total` follows
12
12
  * ```
13
13
  *
14
14
  * The first is a diff. The second localises. Only the third names a cause, and
@@ -7,7 +7,7 @@
7
7
  * ```text
8
8
  * a <div> rendered <p> on one side and <span> on the other
9
9
  * Summary was handed a different `total`
10
- * Cart chose differently — useState #2 moved, and Summary's `total` follows
10
+ * Cart chose differently — useState #2 changed, and Summary's `total` follows
11
11
  * ```
12
12
  *
13
13
  * The first is a diff. The second localises. Only the third names a cause, and
@@ -68,9 +68,9 @@ function sentence(boundary) {
68
68
  const { component, inputs } = boundary;
69
69
  switch (boundary.rung) {
70
70
  case 'stateful':
71
- return `${component} chose differently — ${list(inputs.filter(isHook))} moved`;
71
+ return `${component} chose differently — ${list(inputs.filter(isHook))} changed`;
72
72
  case 'external':
73
- return `${component} read a different external store — ${list(inputs.filter(isHook))} moved`;
73
+ return `${component} read a different external store — ${list(inputs.filter(isHook))} changed`;
74
74
  case 'provided':
75
75
  return `${component} was given a different ${list(inputs.filter((i) => i.kind === 'context'))}`;
76
76
  case 'handed':
@@ -83,6 +83,11 @@ function sentence(boundary) {
83
83
  `${list(inputs.filter((i) => i.kind === 'inherited'))} — an ancestor declared it`);
84
84
  case 'undetermined':
85
85
  return `${component} rendered differently from inputs that all agreed — nondeterministic`;
86
+ // Not an accusation. The two readings are two places, and the sentence says
87
+ // where to look — outwards, at what put it there — rather than at the
88
+ // component, which received nothing that could have decided this.
89
+ case 'placed':
90
+ return `${component} rendered differently where it stands — its context decided this`;
86
91
  case 'unread':
87
92
  return `${component} rendered differently, and what it holds could not be read`;
88
93
  case 'unpaired':
@@ -149,17 +154,19 @@ function slice(kind) {
149
154
  case 'settled':
150
155
  return 'settled — the component tree, its inputs and its output all held';
151
156
  case 'variation':
152
- return 'variation — an input moved and the page followed';
157
+ return 'variation — an input changed and the page followed';
153
158
  case 'flake':
154
- return 'flake — every input agreed, the component tree held, and the page moved anyway';
159
+ return 'flake — every input agreed, the component tree held, and the page changed anyway';
160
+ case 'placed':
161
+ return 'placed — every input agreed, and the two readings are of two places';
155
162
  case 'reshaped':
156
163
  return 'reshaped — the component tree is a different tree and the page followed';
157
164
  case 'refactor':
158
- return 'refactor — the component tree moved and the page did not';
165
+ return 'refactor — the component tree changed and the page did not';
159
166
  case 'absorbed':
160
- return 'absorbed — an input moved and the page did not';
167
+ return 'absorbed — an input changed and the page did not';
161
168
  case 'unread':
162
- return 'unread — the page moved and what would explain it was not read';
169
+ return 'unread — the page changed and what would explain it was not read';
163
170
  }
164
171
  }
165
172
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"explain.js","sourceRoot":"","sources":["../../src/compare/explain.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Parting, PartedBoundary, MovedInput } from './parting.js';\nimport type { PartingSlice } from './slice.js';\n\n/**\n * A parting, spoken.\n *\n * The rung a run reached is only worth what a reader takes from it, and the\n * three sentences below are three different products:\n *\n * ```text\n * a <div> rendered <p> on one side and <span> on the other\n * Summary was handed a different `total`\n * Cart chose differently — useState #2 moved, and Summary's `total` follows\n * ```\n *\n * The first is a diff. The second localises. Only the third names a cause, and\n * it is the only one somebody can act on without opening the component and\n * guessing. So the shape here is an **origin and its manifestations**, not a\n * flat list: an origin line, then the boundaries and deltas downstream of it,\n * indented. A collateral boundary with no origin above it gets a line of its own\n * — that is a parting this run could not trace further, and hiding it under\n * something else would overstate what was found.\n */\nexport function explainParting(parting: Parting): readonly string[] {\n const boundaries = parting.boundaries;\n const headline = slice(parting.slice);\n\n if (boundaries === undefined) {\n return [headline, ' no framework boundary was read, so nothing can be said about why'];\n }\n if (boundaries.length === 0) {\n return [headline, ' every boundary read held its inputs and its output'];\n }\n\n const origins = parting.origins ?? [];\n const claimed = new Set<PartedBoundary>();\n const lines: string[] = [headline];\n\n for (const origin of origins) {\n if (claimed.has(origin)) continue;\n claimed.add(origin);\n const downstream = boundaries.filter(\n (other) => !claimed.has(other) && under(origin.path, other.path),\n );\n for (const other of downstream) claimed.add(other);\n\n lines.push(sentence(origin));\n lines.push(...deltaLines(origin, boundaries));\n\n // The avalanche. One input at a fork can put a boundary on every component\n // beneath it, and enumerating them buries the one line worth reading under\n // its own consequences. Past the cap the fan-out is stated as a size —\n // pointing at the fork is the finding, and the boundary list is still there\n // for a caller that wants to walk it.\n if (downstream.length > FANOUT) {\n const deltas = downstream.reduce((total, other) => total + other.deltas, 0);\n lines.push(\n ` manifests across ${downstream.length} boundaries below it` +\n (deltas > 0 ? `, ${deltas} delta${deltas === 1 ? '' : 's'} in all` : ''),\n );\n } else {\n for (const other of downstream) {\n lines.push(` manifests as ${sentence(other)}`);\n lines.push(...deltaLines(other, boundaries).map((line) => ` ${line}`));\n }\n }\n }\n\n for (const rest of boundaries) {\n if (claimed.has(rest)) continue;\n lines.push(sentence(rest));\n lines.push(...deltaLines(rest, boundaries));\n }\n\n return lines;\n}\n\nfunction sentence(boundary: PartedBoundary): string {\n const { component, inputs } = boundary;\n\n switch (boundary.rung) {\n case 'stateful':\n return `${component} chose differently — ${list(inputs.filter(isHook))} moved`;\n case 'external':\n return `${component} read a different external store — ${list(inputs.filter(isHook))} moved`;\n case 'provided':\n return `${component} was given a different ${list(inputs.filter((i) => i.kind === 'context'))}`;\n case 'handed':\n return `${component} was handed a different ${list(inputs.filter((i) => i.kind === 'prop'))}`;\n // Phrased at the ancestor rather than at the component, because that is\n // where the reader has to go. `Price` did nothing; something it is standing\n // inside declared a value it never declares for itself.\n case 'inherited':\n return (\n `${component} inherited a different ` +\n `${list(inputs.filter((i) => i.kind === 'inherited'))} — an ancestor declared it`\n );\n case 'undetermined':\n return `${component} rendered differently from inputs that all agreed — nondeterministic`;\n case 'unread':\n return `${component} rendered differently, and what it holds could not be read`;\n case 'unpaired':\n return `${component} is present on one side only`;\n }\n}\n\n/** What `holdingOf` writes for a context React could not name. */\nconst ANONYMOUS = '(anonymous)';\n\nfunction isHook(input: MovedInput): boolean {\n return input.kind === 'hook';\n}\n\n/**\n * Name the moved inputs, hooks by call position.\n *\n * `useState #2` rather than `useState` because a component with four `useState`\n * calls is the ordinary case, and a sentence naming the hook without saying\n * which one sends the reader back to counting — the work this module exists to\n * have already done.\n */\nfunction list(inputs: readonly MovedInput[]): string {\n const named = inputs.map((input) => {\n if (input.index !== undefined) return `${input.name} #${input.index}`;\n // A `createContext` call with no `displayName` is the common case, and\n // \"a different `(anonymous)`\" names the gap in React's metadata rather than\n // the thing that moved.\n return input.name === ANONYMOUS ? 'context value' : `\\`${input.name}\\``;\n });\n if (named.length === 0) return 'input';\n if (named.length === 1) return named[0] as string;\n return `${named.slice(0, -1).join(', ')} and ${named[named.length - 1] as string}`;\n}\n\n/**\n * The deltas at a boundary, and the one case worth saying nothing about.\n *\n * A boundary owning no delta is only *quiet* if nothing under it owns one\n * either. Otherwise the deltas are real and belong to a nested boundary, whose\n * own line already reports them — and \"and rendered the same anyway\" directly\n * beneath \"chose differently\" reads as a contradiction rather than as the\n * ownership statement it is.\n */\nfunction deltaLines(\n boundary: PartedBoundary,\n all: readonly PartedBoundary[],\n): readonly string[] {\n if (boundary.deltas === 0) {\n const moved = all.some((other) => under(boundary.path, other.path) && other.deltas > 0);\n return moved ? [] : [' and rendered the same anyway'];\n }\n const bands = boundary.bands.join(', ');\n const count = `${boundary.deltas} delta${boundary.deltas === 1 ? '' : 's'} here (${bands})`;\n return [` ${count}${named(boundary.moved)}`];\n}\n\nconst SHOWN = 4;\n\n/** Above this many boundaries under one origin, the fan-out is the finding. */\nconst FANOUT = 3;\n\n/**\n * The triage line, first, before anything about which input moved.\n *\n * Deliberately a whole sentence rather than the bare word: `refactor` alone\n * reads as a label somebody applied, and the clause is the evidence for it.\n */\nfunction slice(kind: PartingSlice): string {\n switch (kind) {\n case 'settled':\n return 'settled — the component tree, its inputs and its output all held';\n case 'variation':\n return 'variation — an input moved and the page followed';\n case 'flake':\n return 'flake — every input agreed, the component tree held, and the page moved anyway';\n case 'reshaped':\n return 'reshaped — the component tree is a different tree and the page followed';\n case 'refactor':\n return 'refactor — the component tree moved and the page did not';\n case 'absorbed':\n return 'absorbed — an input moved and the page did not';\n case 'unread':\n return 'unread — the page moved and what would explain it was not read';\n }\n}\n\n/**\n * The properties that moved, truncated where a list stops being a sentence.\n *\n * Four because `padding` expands to four longhands and a reader who has seen\n * the first learns nothing from the rest. What was dropped is counted rather\n * than elided — a list that quietly ends reads as the whole list.\n */\nfunction named(moved: readonly string[] | undefined): string {\n if (moved === undefined || moved.length === 0) return '';\n if (moved.length <= SHOWN) return ` — ${moved.join(', ')}`;\n return ` — ${moved.slice(0, SHOWN).join(', ')} and ${moved.length - SHOWN} more`;\n}\n\n/**\n * Whether one path encloses another, strictly.\n *\n * Segment-aware: `0/1` must not swallow `0/10`, and a plain `startsWith` would\n * make every tenth sibling collateral of its second.\n */\nfunction under(ancestor: string, path: string): boolean {\n if (ancestor === path) return false;\n return ancestor === '' || path.startsWith(`${ancestor}/`);\n}\n"]}
1
+ {"version":3,"file":"explain.js","sourceRoot":"","sources":["../../src/compare/explain.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Parting, PartedBoundary, MovedInput } from './parting.js';\nimport type { PartingSlice } from './slice.js';\n\n/**\n * A parting, spoken.\n *\n * The rung a run reached is only worth what a reader takes from it, and the\n * three sentences below are three different products:\n *\n * ```text\n * a <div> rendered <p> on one side and <span> on the other\n * Summary was handed a different `total`\n * Cart chose differently — useState #2 changed, and Summary's `total` follows\n * ```\n *\n * The first is a diff. The second localises. Only the third names a cause, and\n * it is the only one somebody can act on without opening the component and\n * guessing. So the shape here is an **origin and its manifestations**, not a\n * flat list: an origin line, then the boundaries and deltas downstream of it,\n * indented. A collateral boundary with no origin above it gets a line of its own\n * — that is a parting this run could not trace further, and hiding it under\n * something else would overstate what was found.\n */\nexport function explainParting(parting: Parting): readonly string[] {\n const boundaries = parting.boundaries;\n const headline = slice(parting.slice);\n\n if (boundaries === undefined) {\n return [headline, ' no framework boundary was read, so nothing can be said about why'];\n }\n if (boundaries.length === 0) {\n return [headline, ' every boundary read held its inputs and its output'];\n }\n\n const origins = parting.origins ?? [];\n const claimed = new Set<PartedBoundary>();\n const lines: string[] = [headline];\n\n for (const origin of origins) {\n if (claimed.has(origin)) continue;\n claimed.add(origin);\n const downstream = boundaries.filter(\n (other) => !claimed.has(other) && under(origin.path, other.path),\n );\n for (const other of downstream) claimed.add(other);\n\n lines.push(sentence(origin));\n lines.push(...deltaLines(origin, boundaries));\n\n // The avalanche. One input at a fork can put a boundary on every component\n // beneath it, and enumerating them buries the one line worth reading under\n // its own consequences. Past the cap the fan-out is stated as a size —\n // pointing at the fork is the finding, and the boundary list is still there\n // for a caller that wants to walk it.\n if (downstream.length > FANOUT) {\n const deltas = downstream.reduce((total, other) => total + other.deltas, 0);\n lines.push(\n ` manifests across ${downstream.length} boundaries below it` +\n (deltas > 0 ? `, ${deltas} delta${deltas === 1 ? '' : 's'} in all` : ''),\n );\n } else {\n for (const other of downstream) {\n lines.push(` manifests as ${sentence(other)}`);\n lines.push(...deltaLines(other, boundaries).map((line) => ` ${line}`));\n }\n }\n }\n\n for (const rest of boundaries) {\n if (claimed.has(rest)) continue;\n lines.push(sentence(rest));\n lines.push(...deltaLines(rest, boundaries));\n }\n\n return lines;\n}\n\nfunction sentence(boundary: PartedBoundary): string {\n const { component, inputs } = boundary;\n\n switch (boundary.rung) {\n case 'stateful':\n return `${component} chose differently — ${list(inputs.filter(isHook))} changed`;\n case 'external':\n return `${component} read a different external store — ${list(inputs.filter(isHook))} changed`;\n case 'provided':\n return `${component} was given a different ${list(inputs.filter((i) => i.kind === 'context'))}`;\n case 'handed':\n return `${component} was handed a different ${list(inputs.filter((i) => i.kind === 'prop'))}`;\n // Phrased at the ancestor rather than at the component, because that is\n // where the reader has to go. `Price` did nothing; something it is standing\n // inside declared a value it never declares for itself.\n case 'inherited':\n return (\n `${component} inherited a different ` +\n `${list(inputs.filter((i) => i.kind === 'inherited'))} — an ancestor declared it`\n );\n case 'undetermined':\n return `${component} rendered differently from inputs that all agreed — nondeterministic`;\n // Not an accusation. The two readings are two places, and the sentence says\n // where to look — outwards, at what put it there — rather than at the\n // component, which received nothing that could have decided this.\n case 'placed':\n return `${component} rendered differently where it stands — its context decided this`;\n case 'unread':\n return `${component} rendered differently, and what it holds could not be read`;\n case 'unpaired':\n return `${component} is present on one side only`;\n }\n}\n\n/** What `holdingOf` writes for a context React could not name. */\nconst ANONYMOUS = '(anonymous)';\n\nfunction isHook(input: MovedInput): boolean {\n return input.kind === 'hook';\n}\n\n/**\n * Name the moved inputs, hooks by call position.\n *\n * `useState #2` rather than `useState` because a component with four `useState`\n * calls is the ordinary case, and a sentence naming the hook without saying\n * which one sends the reader back to counting — the work this module exists to\n * have already done.\n */\nfunction list(inputs: readonly MovedInput[]): string {\n const named = inputs.map((input) => {\n if (input.index !== undefined) return `${input.name} #${input.index}`;\n // A `createContext` call with no `displayName` is the common case, and\n // \"a different `(anonymous)`\" names the gap in React's metadata rather than\n // the thing that moved.\n return input.name === ANONYMOUS ? 'context value' : `\\`${input.name}\\``;\n });\n if (named.length === 0) return 'input';\n if (named.length === 1) return named[0] as string;\n return `${named.slice(0, -1).join(', ')} and ${named[named.length - 1] as string}`;\n}\n\n/**\n * The deltas at a boundary, and the one case worth saying nothing about.\n *\n * A boundary owning no delta is only *quiet* if nothing under it owns one\n * either. Otherwise the deltas are real and belong to a nested boundary, whose\n * own line already reports them — and \"and rendered the same anyway\" directly\n * beneath \"chose differently\" reads as a contradiction rather than as the\n * ownership statement it is.\n */\nfunction deltaLines(\n boundary: PartedBoundary,\n all: readonly PartedBoundary[],\n): readonly string[] {\n if (boundary.deltas === 0) {\n const moved = all.some((other) => under(boundary.path, other.path) && other.deltas > 0);\n return moved ? [] : [' and rendered the same anyway'];\n }\n const bands = boundary.bands.join(', ');\n const count = `${boundary.deltas} delta${boundary.deltas === 1 ? '' : 's'} here (${bands})`;\n return [` ${count}${named(boundary.moved)}`];\n}\n\nconst SHOWN = 4;\n\n/** Above this many boundaries under one origin, the fan-out is the finding. */\nconst FANOUT = 3;\n\n/**\n * The triage line, first, before anything about which input moved.\n *\n * Deliberately a whole sentence rather than the bare word: `refactor` alone\n * reads as a label somebody applied, and the clause is the evidence for it.\n */\nfunction slice(kind: PartingSlice): string {\n switch (kind) {\n case 'settled':\n return 'settled — the component tree, its inputs and its output all held';\n case 'variation':\n return 'variation — an input changed and the page followed';\n case 'flake':\n return 'flake — every input agreed, the component tree held, and the page changed anyway';\n case 'placed':\n return 'placed — every input agreed, and the two readings are of two places';\n case 'reshaped':\n return 'reshaped — the component tree is a different tree and the page followed';\n case 'refactor':\n return 'refactor — the component tree changed and the page did not';\n case 'absorbed':\n return 'absorbed — an input changed and the page did not';\n case 'unread':\n return 'unread — the page changed and what would explain it was not read';\n }\n}\n\n/**\n * The properties that moved, truncated where a list stops being a sentence.\n *\n * Four because `padding` expands to four longhands and a reader who has seen\n * the first learns nothing from the rest. What was dropped is counted rather\n * than elided — a list that quietly ends reads as the whole list.\n */\nfunction named(moved: readonly string[] | undefined): string {\n if (moved === undefined || moved.length === 0) return '';\n if (moved.length <= SHOWN) return ` — ${moved.join(', ')}`;\n return ` — ${moved.slice(0, SHOWN).join(', ')} and ${moved.length - SHOWN} more`;\n}\n\n/**\n * Whether one path encloses another, strictly.\n *\n * Segment-aware: `0/1` must not swallow `0/10`, and a plain `startsWith` would\n * make every tenth sibling collateral of its second.\n */\nfunction under(ancestor: string, path: string): boolean {\n if (ancestor === path) return false;\n return ancestor === '' || path.startsWith(`${ancestor}/`);\n}\n"]}
@@ -15,7 +15,7 @@ export { impactOf, canReflow, aggregateImpact } from './impact.js';
15
15
  export type { PropertyImpact, AggregateImpact } from './impact.js';
16
16
  export type { Band, DeltaKind } from './band.js';
17
17
  export { BANDS, bandOf, loudestBand } from './band.js';
18
- export { observableBands, decidesBand } from './observability.js';
18
+ export { observableBands, decidesBand, weaker, sharedObservability, unobservedBands, narrowedBands, } from './observability.js';
19
19
  export type { Observability } from './observability.js';
20
20
  export { deriveVariation } from './derive.js';
21
21
  export type { Variation } from './derive.js';
@@ -23,7 +23,7 @@ export { partingOf } from './parting.js';
23
23
  export { boundarySnapshot } from './instance.js';
24
24
  export { sliceOf, sameTree } from './slice.js';
25
25
  export type { PartingSlice } from './slice.js';
26
- export type { Parting, PartedBoundary, PartingRung, MovedInput, } from './parting.js';
26
+ export type { Parting, PartedBoundary, PartingRung, PartingPlace, MovedInput, } from './parting.js';
27
27
  export { explainParting } from './explain.js';
28
28
  export { compareValues, fingerprintOfValueDelta } from './value.js';
29
29
  export type { ValueChange, ValueDelta } from './value.js';
@@ -12,7 +12,7 @@
12
12
  export { diffSnapshots, matchTrees } from './diff/index.js';
13
13
  export { impactOf, canReflow, aggregateImpact } from './impact.js';
14
14
  export { BANDS, bandOf, loudestBand } from './band.js';
15
- export { observableBands, decidesBand } from './observability.js';
15
+ export { observableBands, decidesBand, weaker, sharedObservability, unobservedBands, narrowedBands, } from './observability.js';
16
16
  export { deriveVariation } from './derive.js';
17
17
  export { partingOf } from './parting.js';
18
18
  export { boundarySnapshot } from './instance.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/compare/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAU5D,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAInE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAEvD,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGlE,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAS/C,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["/**\n * `@variance-authority/core/compare` — two snapshots become a list of deltas.\n *\n * Tree matching, the delta vocabulary, the bands a delta falls into, and what a\n * changed property is capable of doing to a layout. No verdict: this group says\n * *what moved*, and refuses to say whether anyone should mind.\n *\n * The split from `judge` is the load-bearing one. A comparison that also decided\n * severity could not be reused by a caller with a different policy, and every\n * team has a different policy.\n */\n\nexport { diffSnapshots, matchTrees } from './diff/index.js';\nexport type {\n SemanticDiff,\n Delta,\n Root,\n RootKind,\n ChangedComponent,\n Matching,\n} from './diff/index.js';\n\nexport { impactOf, canReflow, aggregateImpact } from './impact.js';\nexport type { PropertyImpact, AggregateImpact } from './impact.js';\n\nexport type { Band, DeltaKind } from './band.js';\nexport { BANDS, bandOf, loudestBand } from './band.js';\n\nexport { observableBands, decidesBand } from './observability.js';\nexport type { Observability } from './observability.js';\n\nexport { deriveVariation } from './derive.js';\nexport type { Variation } from './derive.js';\n\nexport { partingOf } from './parting.js';\nexport { boundarySnapshot } from './instance.js';\nexport { sliceOf, sameTree } from './slice.js';\nexport type { PartingSlice } from './slice.js';\nexport type {\n Parting,\n PartedBoundary,\n PartingRung,\n MovedInput,\n} from './parting.js';\n\nexport { explainParting } from './explain.js';\n\nexport { compareValues, fingerprintOfValueDelta } from './value.js';\nexport type { ValueChange, ValueDelta } from './value.js';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/compare/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAU5D,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAInE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAEvD,OAAO,EACL,eAAe,EACf,WAAW,EACX,MAAM,EACN,mBAAmB,EACnB,eAAe,EACf,aAAa,GACd,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAU/C,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["/**\n * `@variance-authority/core/compare` — two snapshots become a list of deltas.\n *\n * Tree matching, the delta vocabulary, the bands a delta falls into, and what a\n * changed property is capable of doing to a layout. No verdict: this group says\n * *what moved*, and refuses to say whether anyone should mind.\n *\n * The split from `judge` is the load-bearing one. A comparison that also decided\n * severity could not be reused by a caller with a different policy, and every\n * team has a different policy.\n */\n\nexport { diffSnapshots, matchTrees } from './diff/index.js';\nexport type {\n SemanticDiff,\n Delta,\n Root,\n RootKind,\n ChangedComponent,\n Matching,\n} from './diff/index.js';\n\nexport { impactOf, canReflow, aggregateImpact } from './impact.js';\nexport type { PropertyImpact, AggregateImpact } from './impact.js';\n\nexport type { Band, DeltaKind } from './band.js';\nexport { BANDS, bandOf, loudestBand } from './band.js';\n\nexport {\n observableBands,\n decidesBand,\n weaker,\n sharedObservability,\n unobservedBands,\n narrowedBands,\n} from './observability.js';\nexport type { Observability } from './observability.js';\n\nexport { deriveVariation } from './derive.js';\nexport type { Variation } from './derive.js';\n\nexport { partingOf } from './parting.js';\nexport { boundarySnapshot } from './instance.js';\nexport { sliceOf, sameTree } from './slice.js';\nexport type { PartingSlice } from './slice.js';\nexport type {\n Parting,\n PartedBoundary,\n PartingRung,\n PartingPlace,\n MovedInput,\n} from './parting.js';\n\nexport { explainParting } from './explain.js';\n\nexport { compareValues, fingerprintOfValueDelta } from './value.js';\nexport type { ValueChange, ValueDelta } from './value.js';\n"]}