my-frontend-observer 0.3.0 → 0.5.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 (77) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +59 -7
  3. package/dist/application/comparisonService.d.ts +56 -0
  4. package/dist/application/comparisonService.js +77 -0
  5. package/dist/application/comparisonService.js.map +1 -0
  6. package/dist/application/frontendContractEvaluationService.d.ts +49 -0
  7. package/dist/application/frontendContractEvaluationService.js +112 -0
  8. package/dist/application/frontendContractEvaluationService.js.map +1 -0
  9. package/dist/application/frontendContractPersistenceService.d.ts +56 -0
  10. package/dist/application/frontendContractPersistenceService.js +91 -0
  11. package/dist/application/frontendContractPersistenceService.js.map +1 -0
  12. package/dist/artifacts/artifactReader.d.ts +19 -0
  13. package/dist/artifacts/artifactReader.js +36 -0
  14. package/dist/artifacts/artifactReader.js.map +1 -0
  15. package/dist/artifacts/comparisonArtifactReader.d.ts +18 -0
  16. package/dist/artifacts/comparisonArtifactReader.js +35 -0
  17. package/dist/artifacts/comparisonArtifactReader.js.map +1 -0
  18. package/dist/artifacts/comparisonArtifactWriter.d.ts +41 -0
  19. package/dist/artifacts/comparisonArtifactWriter.js +67 -0
  20. package/dist/artifacts/comparisonArtifactWriter.js.map +1 -0
  21. package/dist/artifacts/frontendContractArtifactReader.d.ts +24 -0
  22. package/dist/artifacts/frontendContractArtifactReader.js +47 -0
  23. package/dist/artifacts/frontendContractArtifactReader.js.map +1 -0
  24. package/dist/artifacts/frontendContractArtifactWriter.d.ts +34 -0
  25. package/dist/artifacts/frontendContractArtifactWriter.js +70 -0
  26. package/dist/artifacts/frontendContractArtifactWriter.js.map +1 -0
  27. package/dist/artifacts/frontendContractEvaluationArtifactReader.d.ts +17 -0
  28. package/dist/artifacts/frontendContractEvaluationArtifactReader.js +34 -0
  29. package/dist/artifacts/frontendContractEvaluationArtifactReader.js.map +1 -0
  30. package/dist/artifacts/frontendContractEvaluationArtifactWriter.d.ts +32 -0
  31. package/dist/artifacts/frontendContractEvaluationArtifactWriter.js +58 -0
  32. package/dist/artifacts/frontendContractEvaluationArtifactWriter.js.map +1 -0
  33. package/dist/cli.js +712 -3
  34. package/dist/cli.js.map +1 -1
  35. package/dist/domain/comparison.d.ts +198 -0
  36. package/dist/domain/comparison.js +324 -0
  37. package/dist/domain/comparison.js.map +1 -0
  38. package/dist/domain/comparisonEngine.d.ts +48 -0
  39. package/dist/domain/comparisonEngine.js +694 -0
  40. package/dist/domain/comparisonEngine.js.map +1 -0
  41. package/dist/domain/comparisonIdentity.d.ts +13 -0
  42. package/dist/domain/comparisonIdentity.js +46 -0
  43. package/dist/domain/comparisonIdentity.js.map +1 -0
  44. package/dist/domain/evidence.d.ts +2 -0
  45. package/dist/domain/evidence.js +4 -0
  46. package/dist/domain/evidence.js.map +1 -1
  47. package/dist/domain/frontendContractEvaluation.d.ts +57 -0
  48. package/dist/domain/frontendContractEvaluation.js +454 -0
  49. package/dist/domain/frontendContractEvaluation.js.map +1 -0
  50. package/dist/domain/frontendContractEvaluationArtifact.d.ts +65 -0
  51. package/dist/domain/frontendContractEvaluationArtifact.js +108 -0
  52. package/dist/domain/frontendContractEvaluationArtifact.js.map +1 -0
  53. package/dist/domain/frontendContractIdentity.d.ts +39 -0
  54. package/dist/domain/frontendContractIdentity.js +70 -0
  55. package/dist/domain/frontendContractIdentity.js.map +1 -0
  56. package/dist/domain/frontendContracts.d.ts +188 -0
  57. package/dist/domain/frontendContracts.js +260 -0
  58. package/dist/domain/frontendContracts.js.map +1 -0
  59. package/dist/domain/relationships.d.ts +168 -0
  60. package/dist/domain/relationships.js +343 -0
  61. package/dist/domain/relationships.js.map +1 -0
  62. package/dist/index.d.ts +36 -1
  63. package/dist/index.js +20 -1
  64. package/dist/index.js.map +1 -1
  65. package/docs/ARCHITECTURE.md +173 -3
  66. package/docs/CI_CD.md +76 -0
  67. package/docs/COMMANDS.md +299 -0
  68. package/docs/CONTRACTS.md +270 -6
  69. package/docs/CURRENT_STATE.md +183 -8
  70. package/docs/DEVELOPMENT.md +83 -12
  71. package/docs/PROJECT_OVERVIEW.md +18 -8
  72. package/docs/QUICKSTART.md +10 -0
  73. package/docs/RELEASE.md +17 -8
  74. package/docs/ROADMAP.md +10 -0
  75. package/docs/SECURITY.md +26 -1
  76. package/docs/WORKFLOWS.md +113 -6
  77. package/package.json +1 -1
@@ -0,0 +1,694 @@
1
+ import { evidenceValue } from './evidence.js';
2
+ import { isValidObservationArtifact, getProducerInfo } from './schema.js';
3
+ import { deriveOverflowEvidence } from './scrollEvidence.js';
4
+ import { deriveLayoutRelationships, deriveTargetClipping, isValidGeometryTolerancePx, GEOMETRY_TOLERANCE_DEFAULT_PX } from './relationships.js';
5
+ import { COMPARISON_ARTIFACT_KIND, COMPARISON_SCHEMA_VERSION, COMPARABILITY_REASON_SEVERITY, COMPARABILITY_REASON_CODES, isValidComparisonConfig, isValidComparisonArtifact, } from './comparison.js';
6
+ import { buildComparisonRequestIdentity, buildComparisonIdentity } from './comparisonIdentity.js';
7
+ import { DIAGNOSTIC_SEVERITY } from './diagnostics.js';
8
+ // --- small local helpers -----------------------------------------------------
9
+ /** `evidenceValue` for a field that may itself be absent from a dictionary lookup (e.g. `pageEvidence['someKey']`, an optional `containment` cast) - never fabricates a value for a missing field. */
10
+ function fieldValue(field) {
11
+ return field ? evidenceValue(field) : undefined;
12
+ }
13
+ function normalizedTargetMap(targets) {
14
+ const map = new Map();
15
+ for (const target of targets)
16
+ map.set(target.name.toLowerCase(), target);
17
+ return map;
18
+ }
19
+ /** Deterministic order: before's configured order, restricted to names also configured after (see domain/comparisonEngine.ts#compareTargetConfiguration for the matching addition/removal ordering). */
20
+ function commonTargets(before, after) {
21
+ const afterMap = normalizedTargetMap(after.requestConfig.targets);
22
+ const results = [];
23
+ for (const target of before.requestConfig.targets) {
24
+ const afterTarget = afterMap.get(target.name.toLowerCase());
25
+ if (afterTarget)
26
+ results.push({ canonicalName: target.name, beforeName: target.name, afterName: afterTarget.name });
27
+ }
28
+ return results;
29
+ }
30
+ /**
31
+ * Configuration-change evidence: `removed`/`locator-changed` in before's
32
+ * configured order, then `added` in after's configured order (Batch 1's
33
+ * frozen ordering guidance) - never a source of appeared/disappeared
34
+ * rendered-state differences by itself.
35
+ */
36
+ export function compareTargetConfiguration(before, after) {
37
+ const changes = [];
38
+ const beforeMap = normalizedTargetMap(before.requestConfig.targets);
39
+ const afterMap = normalizedTargetMap(after.requestConfig.targets);
40
+ for (const target of before.requestConfig.targets) {
41
+ const key = target.name.toLowerCase();
42
+ const afterTarget = afterMap.get(key);
43
+ if (!afterTarget) {
44
+ changes.push({ kind: 'removed', target: target.name });
45
+ }
46
+ else if (JSON.stringify(target.locators) !== JSON.stringify(afterTarget.locators)) {
47
+ changes.push({ kind: 'locator-changed', target: target.name });
48
+ }
49
+ }
50
+ for (const target of after.requestConfig.targets) {
51
+ if (!beforeMap.has(target.name.toLowerCase()))
52
+ changes.push({ kind: 'added', target: target.name });
53
+ }
54
+ return changes;
55
+ }
56
+ function isSameScrollScenario(before, after) {
57
+ if (before === undefined && after === undefined)
58
+ return true;
59
+ if (before === undefined || after === undefined)
60
+ return false;
61
+ const a = before.action;
62
+ const b = after.action;
63
+ if (a.kind !== b.kind)
64
+ return false;
65
+ if (a.deltaX !== b.deltaX || a.deltaY !== b.deltaY)
66
+ return false;
67
+ if (a.kind === 'target-scroll-by' && b.kind === 'target-scroll-by') {
68
+ return a.target.toLowerCase() === b.target.toLowerCase();
69
+ }
70
+ return true;
71
+ }
72
+ // --- comparability -----------------------------------------------------------
73
+ /**
74
+ * Evaluates comparability before any rendered difference is calculated
75
+ * (Batch 1's frozen `ComparabilityResult`/reason vocabulary). Hard
76
+ * incompatibilities (`blocking`) force `incomparable`; producer/browser
77
+ * version and target-configuration mismatches are `warning`-only; the three
78
+ * state dimensions the observer never models (theme, authenticated-state,
79
+ * application-state) are always recorded as `unassessed` - never silently
80
+ * claimed identical.
81
+ */
82
+ export function evaluateComparability(before, after) {
83
+ const reasons = [];
84
+ const add = (code, message) => {
85
+ reasons.push({ code, severity: COMPARABILITY_REASON_SEVERITY[code], message });
86
+ };
87
+ if (before.artifactKind !== after.artifactKind) {
88
+ add('artifact-kind-mismatch', `before artifactKind "${before.artifactKind}" differs from after artifactKind "${after.artifactKind}"`);
89
+ }
90
+ if (before.schemaVersion !== after.schemaVersion) {
91
+ add('schema-version-mismatch', `before schemaVersion "${before.schemaVersion}" differs from after schemaVersion "${after.schemaVersion}"`);
92
+ }
93
+ if (before.requestConfig.targetUrl !== after.requestConfig.targetUrl) {
94
+ add('page-url-mismatch', `before targetUrl "${before.requestConfig.targetUrl}" differs from after targetUrl "${after.requestConfig.targetUrl}"`);
95
+ }
96
+ if (before.requestConfig.viewport.width !== after.requestConfig.viewport.width || before.requestConfig.viewport.height !== after.requestConfig.viewport.height) {
97
+ add('viewport-mismatch', `before viewport ${before.requestConfig.viewport.width}x${before.requestConfig.viewport.height} differs from after viewport ${after.requestConfig.viewport.width}x${after.requestConfig.viewport.height}`);
98
+ }
99
+ const beforeBrowser = evidenceValue(before.browser);
100
+ const afterBrowser = evidenceValue(after.browser);
101
+ if (beforeBrowser && afterBrowser && beforeBrowser.engine !== afterBrowser.engine) {
102
+ add('browser-engine-mismatch', `before browser engine "${beforeBrowser.engine}" differs from after browser engine "${afterBrowser.engine}"`);
103
+ }
104
+ if (beforeBrowser && afterBrowser && beforeBrowser.version !== afterBrowser.version) {
105
+ add('browser-version-mismatch', `before browser version "${beforeBrowser.version}" differs from after browser version "${afterBrowser.version}"`);
106
+ }
107
+ if (!isSameScrollScenario(before.requestConfig.scrollScenario, after.requestConfig.scrollScenario)) {
108
+ add('scroll-scenario-mismatch', 'before and after requestConfig.scrollScenario are not the same semantic scroll scenario');
109
+ }
110
+ if (before.producer.version !== after.producer.version) {
111
+ add('producer-version-mismatch', `before producer version "${before.producer.version}" differs from after producer version "${after.producer.version}"`);
112
+ }
113
+ const configurationChanges = compareTargetConfiguration(before, after);
114
+ if (configurationChanges.some((change) => change.kind === 'added' || change.kind === 'removed')) {
115
+ add('target-set-mismatch', 'configured target sets differ between before and after');
116
+ }
117
+ if (configurationChanges.some((change) => change.kind === 'locator-changed')) {
118
+ add('target-locator-mismatch', 'a stable target name has a changed locator configuration between before and after');
119
+ }
120
+ add('theme-unassessed', 'theme identity is not modeled by the observer');
121
+ add('authenticated-state-unassessed', 'authenticated-state identity is not modeled by the observer');
122
+ add('application-state-unassessed', 'application-state identity is not modeled by the observer');
123
+ reasons.sort((a, b) => COMPARABILITY_REASON_CODES.indexOf(a.code) - COMPARABILITY_REASON_CODES.indexOf(b.code));
124
+ const hasBlocking = reasons.some((reason) => reason.severity === 'blocking');
125
+ const hasWarning = reasons.some((reason) => reason.severity === 'warning');
126
+ const state = hasBlocking ? 'incomparable' : hasWarning ? 'comparable-with-warnings' : 'comparable';
127
+ return { state, reasons };
128
+ }
129
+ function targetPresence(record) {
130
+ if (!record)
131
+ return 'unavailable';
132
+ const resolution = evidenceValue(record.resolution);
133
+ if (!resolution)
134
+ return 'unavailable';
135
+ if (resolution.selectionStatus === 'matched')
136
+ return 'matched';
137
+ if (resolution.selectionStatus === 'not-found')
138
+ return 'not-found';
139
+ if (resolution.selectionStatus === 'ambiguous')
140
+ return 'ambiguous';
141
+ return 'unavailable';
142
+ }
143
+ function geometryEvidenceRef(name) {
144
+ return [{ path: `targetEvidence.${name}.geometry` }];
145
+ }
146
+ function targetOverflow(record) {
147
+ const layout = evidenceValue(record.layout);
148
+ const style = evidenceValue(record.style);
149
+ if (!layout || !style)
150
+ return undefined;
151
+ return deriveOverflowEvidence(layout, style.overflowX, style.overflowY);
152
+ }
153
+ /**
154
+ * Direct per-target rendered differences (moved/resized/visibility/clipping/
155
+ * actual-overflow) plus appeared/disappeared, for one common (same stable
156
+ * name both sides) target. `diagnostics` records an honest inability to
157
+ * assess rendered state (ambiguous/unavailable on either side) rather than
158
+ * fabricating absence - see Batch 1's frozen "ambiguous/unavailable is not
159
+ * definitely absent" rule.
160
+ */
161
+ function compareOneTarget(before, after, target, tolerance, diagnostics) {
162
+ const differences = [];
163
+ const beforeRecord = before.targetEvidence[target.beforeName];
164
+ const afterRecord = after.targetEvidence[target.afterName];
165
+ const beforeStatus = targetPresence(beforeRecord);
166
+ const afterStatus = targetPresence(afterRecord);
167
+ const subject = { type: 'target', target: target.canonicalName };
168
+ if (beforeStatus === 'not-found' && afterStatus === 'matched') {
169
+ differences.push({
170
+ kind: 'appeared',
171
+ subject,
172
+ before: 'not-found',
173
+ after: 'matched',
174
+ classification: 'presence',
175
+ beforeObservationId: before.observationId,
176
+ afterObservationId: after.observationId,
177
+ evidence: geometryEvidenceRef(target.afterName),
178
+ });
179
+ return differences;
180
+ }
181
+ if (beforeStatus === 'matched' && afterStatus === 'not-found') {
182
+ differences.push({
183
+ kind: 'disappeared',
184
+ subject,
185
+ before: 'matched',
186
+ after: 'not-found',
187
+ classification: 'presence',
188
+ beforeObservationId: before.observationId,
189
+ afterObservationId: after.observationId,
190
+ evidence: geometryEvidenceRef(target.beforeName),
191
+ });
192
+ return differences;
193
+ }
194
+ if (beforeStatus === 'not-found' && afterStatus === 'not-found') {
195
+ return differences;
196
+ }
197
+ if (beforeStatus !== 'matched' || afterStatus !== 'matched') {
198
+ const code = beforeStatus === 'ambiguous' || afterStatus === 'ambiguous' ? 'target-ambiguous' : 'browser-evidence-unavailable';
199
+ diagnostics.push({
200
+ code,
201
+ severity: DIAGNOSTIC_SEVERITY[code],
202
+ message: `target "${target.canonicalName}" rendered-state comparison unavailable (before: ${beforeStatus}, after: ${afterStatus})`,
203
+ targetName: target.canonicalName,
204
+ });
205
+ return differences;
206
+ }
207
+ // Both sides matched: compare rendered state directly.
208
+ const beforeGeometry = beforeRecord ? evidenceValue(beforeRecord.geometry) : undefined;
209
+ const afterGeometry = afterRecord ? evidenceValue(afterRecord.geometry) : undefined;
210
+ if (beforeGeometry && afterGeometry) {
211
+ const dx = afterGeometry.x - beforeGeometry.x;
212
+ const dy = afterGeometry.y - beforeGeometry.y;
213
+ if (Math.abs(dx) > tolerance || Math.abs(dy) > tolerance) {
214
+ differences.push({
215
+ kind: 'moved',
216
+ subject,
217
+ before: { x: beforeGeometry.x, y: beforeGeometry.y },
218
+ after: { x: afterGeometry.x, y: afterGeometry.y },
219
+ delta: { x: dx, y: dy },
220
+ classification: 'position',
221
+ beforeObservationId: before.observationId,
222
+ afterObservationId: after.observationId,
223
+ evidence: [...geometryEvidenceRef(target.beforeName), ...geometryEvidenceRef(target.afterName)],
224
+ });
225
+ }
226
+ const dw = afterGeometry.width - beforeGeometry.width;
227
+ const dh = afterGeometry.height - beforeGeometry.height;
228
+ if (Math.abs(dw) > tolerance || Math.abs(dh) > tolerance) {
229
+ differences.push({
230
+ kind: 'resized',
231
+ subject,
232
+ before: { width: beforeGeometry.width, height: beforeGeometry.height },
233
+ after: { width: afterGeometry.width, height: afterGeometry.height },
234
+ delta: { width: dw, height: dh },
235
+ classification: 'size',
236
+ beforeObservationId: before.observationId,
237
+ afterObservationId: after.observationId,
238
+ evidence: [...geometryEvidenceRef(target.beforeName), ...geometryEvidenceRef(target.afterName)],
239
+ });
240
+ }
241
+ }
242
+ const beforeVisible = beforeRecord ? evidenceValue(beforeRecord.visibility)?.visible : undefined;
243
+ const afterVisible = afterRecord ? evidenceValue(afterRecord.visibility)?.visible : undefined;
244
+ if (beforeVisible !== undefined && afterVisible !== undefined && beforeVisible !== afterVisible) {
245
+ differences.push({
246
+ kind: 'visibility-changed',
247
+ subject,
248
+ before: beforeVisible,
249
+ after: afterVisible,
250
+ classification: 'visibility',
251
+ beforeObservationId: before.observationId,
252
+ afterObservationId: after.observationId,
253
+ evidence: [{ path: `targetEvidence.${target.beforeName}.visibility` }, { path: `targetEvidence.${target.afterName}.visibility` }],
254
+ });
255
+ }
256
+ if (beforeRecord && afterRecord) {
257
+ const beforeClip = deriveTargetClipping(beforeRecord);
258
+ const afterClip = deriveTargetClipping(afterRecord);
259
+ const clipEvidence = [{ path: `targetEvidence.${target.beforeName}.layout` }, { path: `targetEvidence.${target.afterName}.layout` }];
260
+ if (beforeClip.horizontal !== 'unavailable' && afterClip.horizontal !== 'unavailable' && beforeClip.horizontal !== afterClip.horizontal) {
261
+ differences.push({
262
+ kind: 'clipping-changed',
263
+ subject,
264
+ before: beforeClip.horizontal,
265
+ after: afterClip.horizontal,
266
+ classification: 'horizontal',
267
+ beforeObservationId: before.observationId,
268
+ afterObservationId: after.observationId,
269
+ evidence: clipEvidence,
270
+ });
271
+ }
272
+ if (beforeClip.vertical !== 'unavailable' && afterClip.vertical !== 'unavailable' && beforeClip.vertical !== afterClip.vertical) {
273
+ differences.push({
274
+ kind: 'clipping-changed',
275
+ subject,
276
+ before: beforeClip.vertical,
277
+ after: afterClip.vertical,
278
+ classification: 'vertical',
279
+ beforeObservationId: before.observationId,
280
+ afterObservationId: after.observationId,
281
+ evidence: clipEvidence,
282
+ });
283
+ }
284
+ const beforeOverflow = targetOverflow(beforeRecord);
285
+ const afterOverflow = targetOverflow(afterRecord);
286
+ if (beforeOverflow && afterOverflow) {
287
+ if (beforeOverflow.horizontalOverflow !== afterOverflow.horizontalOverflow) {
288
+ differences.push({
289
+ kind: 'horizontal-overflow-changed',
290
+ subject,
291
+ before: beforeOverflow.horizontalOverflow,
292
+ after: afterOverflow.horizontalOverflow,
293
+ classification: 'horizontal',
294
+ beforeObservationId: before.observationId,
295
+ afterObservationId: after.observationId,
296
+ evidence: clipEvidence,
297
+ });
298
+ }
299
+ if (beforeOverflow.verticalOverflow !== afterOverflow.verticalOverflow) {
300
+ differences.push({
301
+ kind: 'vertical-overflow-changed',
302
+ subject,
303
+ before: beforeOverflow.verticalOverflow,
304
+ after: afterOverflow.verticalOverflow,
305
+ classification: 'vertical',
306
+ beforeObservationId: before.observationId,
307
+ afterObservationId: after.observationId,
308
+ evidence: clipEvidence,
309
+ });
310
+ }
311
+ }
312
+ }
313
+ return differences;
314
+ }
315
+ /** Pairwise DOM-containment comparison among matched-both-sides common targets - reuses existing `TargetContainment` evidence directly, never re-derived from geometry (Batch 2's frozen distinction). Only evaluated when the specific pair was actually evaluated on both sides. */
316
+ function compareContainment(before, after, matched) {
317
+ const differences = [];
318
+ for (let i = 0; i < matched.length; i += 1) {
319
+ for (let j = 0; j < matched.length; j += 1) {
320
+ if (i === j)
321
+ continue;
322
+ const subjectTarget = matched[i];
323
+ const otherTarget = matched[j];
324
+ const beforeContainment = fieldValue(before.targetEvidence[subjectTarget.beforeName]?.containment);
325
+ const afterContainment = fieldValue(after.targetEvidence[subjectTarget.afterName]?.containment);
326
+ if (!beforeContainment || !afterContainment)
327
+ continue;
328
+ const beforeEvaluated = beforeContainment.evaluatedTargetIds.includes(otherTarget.beforeName);
329
+ const afterEvaluated = afterContainment.evaluatedTargetIds.includes(otherTarget.afterName);
330
+ if (!beforeEvaluated || !afterEvaluated)
331
+ continue;
332
+ const beforeContained = beforeContainment.containedByTargetIds.includes(otherTarget.beforeName);
333
+ const afterContained = afterContainment.containedByTargetIds.includes(otherTarget.afterName);
334
+ if (beforeContained === afterContained)
335
+ continue;
336
+ differences.push({
337
+ kind: 'containment-changed',
338
+ subject: { type: 'target', target: subjectTarget.canonicalName },
339
+ before: beforeContained,
340
+ after: afterContained,
341
+ classification: `contained-by:${otherTarget.canonicalName}`,
342
+ beforeObservationId: before.observationId,
343
+ afterObservationId: after.observationId,
344
+ evidence: [
345
+ { path: `targetEvidence.${subjectTarget.beforeName}.containment` },
346
+ { path: `targetEvidence.${subjectTarget.afterName}.containment` },
347
+ ],
348
+ });
349
+ }
350
+ }
351
+ return differences;
352
+ }
353
+ function comparePageSize(before, after, tolerance) {
354
+ const beforeWidth = fieldValue(before.pageEvidence.documentWidth);
355
+ const afterWidth = fieldValue(after.pageEvidence.documentWidth);
356
+ const beforeHeight = fieldValue(before.pageEvidence.documentHeight);
357
+ const afterHeight = fieldValue(after.pageEvidence.documentHeight);
358
+ if (typeof beforeWidth !== 'number' || typeof afterWidth !== 'number' || typeof beforeHeight !== 'number' || typeof afterHeight !== 'number') {
359
+ return [];
360
+ }
361
+ const dw = afterWidth - beforeWidth;
362
+ const dh = afterHeight - beforeHeight;
363
+ if (Math.abs(dw) <= tolerance && Math.abs(dh) <= tolerance)
364
+ return [];
365
+ return [
366
+ {
367
+ kind: 'page-size-changed',
368
+ subject: { type: 'page' },
369
+ before: { width: beforeWidth, height: beforeHeight },
370
+ after: { width: afterWidth, height: afterHeight },
371
+ delta: { width: dw, height: dh },
372
+ classification: 'document-size',
373
+ beforeObservationId: before.observationId,
374
+ afterObservationId: after.observationId,
375
+ evidence: [{ path: 'pageEvidence.documentWidth' }, { path: 'pageEvidence.documentHeight' }],
376
+ },
377
+ ];
378
+ }
379
+ function scrollOwnerEqual(a, b) {
380
+ if (a.kind !== b.kind)
381
+ return false;
382
+ if (a.kind === 'target' && b.kind === 'target')
383
+ return a.target === b.target;
384
+ return true;
385
+ }
386
+ /** Only meaningful when both sides have actually-derived scroll-owner evidence (comparability already ensures the scenario *configuration* matches; this compares the *runtime result*). Never fabricated when either side's evidence is unavailable. */
387
+ function compareScrollOwner(before, after) {
388
+ const beforeOwner = before.scrollScenarioEvidence ? evidenceValue(before.scrollScenarioEvidence.scrollOwner) : undefined;
389
+ const afterOwner = after.scrollScenarioEvidence ? evidenceValue(after.scrollScenarioEvidence.scrollOwner) : undefined;
390
+ if (!beforeOwner || !afterOwner || scrollOwnerEqual(beforeOwner, afterOwner))
391
+ return [];
392
+ return [
393
+ {
394
+ kind: 'scroll-owner-changed',
395
+ subject: { type: 'page' },
396
+ before: beforeOwner,
397
+ after: afterOwner,
398
+ classification: 'scroll-owner',
399
+ beforeObservationId: before.observationId,
400
+ afterObservationId: after.observationId,
401
+ evidence: [{ path: 'scrollScenarioEvidence.scrollOwner' }],
402
+ },
403
+ ];
404
+ }
405
+ // --- relationship-change derivation -------------------------------------------
406
+ const RELATIVE_POSITION_KINDS = [
407
+ 'left-of',
408
+ 'right-of',
409
+ 'horizontally-overlapping',
410
+ 'above',
411
+ 'below',
412
+ 'vertically-overlapping',
413
+ 'overlaps',
414
+ 'does-not-overlap',
415
+ ];
416
+ function isRelativePositionFamily(kind) {
417
+ return RELATIVE_POSITION_KINDS.includes(kind);
418
+ }
419
+ /**
420
+ * A pair carries up to one record *per family* (see
421
+ * `deriveLayoutRelationships`), so looking up "the" record for
422
+ * (subjectTarget, relatedTarget) is ambiguous without also naming the
423
+ * family - matching by subject/related alone would silently return
424
+ * whichever family's record happens to appear first in the array.
425
+ */
426
+ function findPairwiseInFamily(graph, subjectTarget, relatedTarget, family) {
427
+ return graph.pairwiseRelationships.find((r) => r.subjectTarget === subjectTarget && r.relatedTarget === relatedTarget && family.includes(r.kind));
428
+ }
429
+ const RELATIONSHIP_FAMILY_GROUPS = [
430
+ ['left-of', 'right-of', 'horizontally-overlapping'],
431
+ ['above', 'below', 'vertically-overlapping'],
432
+ ['overlaps', 'does-not-overlap'],
433
+ ['wider-than', 'narrower-than', 'equal-width-within-tolerance'],
434
+ ['fits-inside', 'does-not-fit-inside'],
435
+ ['follows-vertically'],
436
+ ];
437
+ /**
438
+ * Matches relationship records by structural identity (family + subject +
439
+ * related target), never by array position. Direction is taken from
440
+ * `before`'s own configured order for each pair; if `after`'s graph placed
441
+ * the same pair in the reversed direction (or the pair became unresolved),
442
+ * the `after` side of the family is honestly reported `'unavailable'`
443
+ * (the frozen escape hatch) rather than guessed/inverted - relationship
444
+ * direction inversion under target-order changes is a known, documented
445
+ * scope limitation, not a fabricated result.
446
+ */
447
+ function deriveRelationshipChanges(relationshipsBefore, relationshipsAfter, matched) {
448
+ const changes = [];
449
+ for (let i = 0; i < matched.length; i += 1) {
450
+ for (let j = i + 1; j < matched.length; j += 1) {
451
+ const x = matched[i];
452
+ const y = matched[j];
453
+ for (const family of RELATIONSHIP_FAMILY_GROUPS) {
454
+ const beforeKind = findPairwiseInFamily(relationshipsBefore, x.beforeName, y.beforeName, family)?.kind;
455
+ const afterKind = findPairwiseInFamily(relationshipsAfter, x.afterName, y.afterName, family)?.kind;
456
+ if (beforeKind === afterKind)
457
+ continue;
458
+ if (beforeKind === undefined && afterKind === undefined)
459
+ continue;
460
+ changes.push({
461
+ scope: 'pairwise',
462
+ kind: (beforeKind ?? afterKind),
463
+ subjectTarget: x.canonicalName,
464
+ relatedTarget: y.canonicalName,
465
+ before: beforeKind ?? 'unavailable',
466
+ after: afterKind ?? 'unavailable',
467
+ beforeObservationId: relationshipsBefore.observationId,
468
+ afterObservationId: relationshipsAfter.observationId,
469
+ });
470
+ }
471
+ }
472
+ }
473
+ const beforePage = relationshipsBefore.pageRelationships[0];
474
+ const afterPage = relationshipsAfter.pageRelationships[0];
475
+ if (beforePage || afterPage) {
476
+ if (beforePage?.kind !== afterPage?.kind) {
477
+ changes.push({
478
+ scope: 'page',
479
+ kind: (beforePage?.kind ?? afterPage?.kind),
480
+ before: beforePage?.kind ?? 'unavailable',
481
+ after: afterPage?.kind ?? 'unavailable',
482
+ beforeObservationId: relationshipsBefore.observationId,
483
+ afterObservationId: relationshipsAfter.observationId,
484
+ });
485
+ }
486
+ }
487
+ return changes;
488
+ }
489
+ /**
490
+ * One user-facing `ComparisonDifference` per relationship change -
491
+ * `relative-position-changed` for the horizontal-order/vertical-order/
492
+ * area-overlap families, `relationship-changed` for the rest (Batch 1's
493
+ * frozen distinction between absolute target movement and a relative
494
+ * relationship transition). Evidence is built directly from the change's own
495
+ * target/page identity - every family's supporting evidence for a given
496
+ * pair is the same two geometry references (see
497
+ * `deriveLayoutRelationships#geometryEvidence`), so no graph lookup (and no
498
+ * risk of picking a different family's record) is needed here.
499
+ */
500
+ function relationshipChangesToDifferences(changes, beforeObservationId, afterObservationId) {
501
+ return changes.map((change) => {
502
+ const kind = change.scope === 'pairwise' && isRelativePositionFamily(change.kind) ? 'relative-position-changed' : 'relationship-changed';
503
+ const subject = change.scope === 'page' || !change.subjectTarget || !change.relatedTarget
504
+ ? { type: 'relationship', kind: change.kind }
505
+ : { type: 'relationship', kind: change.kind, subjectTarget: change.subjectTarget, relatedTarget: change.relatedTarget };
506
+ const evidence = change.scope === 'pairwise' && change.subjectTarget && change.relatedTarget
507
+ ? [{ path: `targetEvidence.${change.subjectTarget}.geometry` }, { path: `targetEvidence.${change.relatedTarget}.geometry` }]
508
+ : [{ path: 'pageEvidence.documentWidth' }, { path: 'pageEvidence.viewportWidth' }];
509
+ return {
510
+ kind,
511
+ subject,
512
+ before: change.before,
513
+ after: change.after,
514
+ classification: change.scope,
515
+ beforeObservationId,
516
+ afterObservationId,
517
+ evidence,
518
+ };
519
+ });
520
+ }
521
+ // --- explicit dependency evaluation -------------------------------------------
522
+ function extractTargetProperty(observation, targetName, property) {
523
+ const record = observation.targetEvidence[targetName];
524
+ if (!record)
525
+ return undefined;
526
+ const geometry = evidenceValue(record.geometry);
527
+ return geometry ? geometry[property] : undefined;
528
+ }
529
+ function evaluateTerm(before, after, direction, tolerance) {
530
+ const delta = after - before;
531
+ switch (direction) {
532
+ case 'increase':
533
+ return { matches: delta > tolerance, contradicts: delta < -tolerance };
534
+ case 'decrease':
535
+ return { matches: delta < -tolerance, contradicts: delta > tolerance };
536
+ case 'change':
537
+ return { matches: Math.abs(delta) > tolerance, contradicts: Math.abs(delta) <= tolerance };
538
+ case 'unchanged':
539
+ return { matches: Math.abs(delta) <= tolerance, contradicts: Math.abs(delta) > tolerance };
540
+ }
541
+ }
542
+ /**
543
+ * Evaluates only explicitly-declared dependencies (`config.expectedDependencies`,
544
+ * in declared order) - never synthesizes a declaration from observed
545
+ * co-change (Batch 1's hard non-causal boundary). Each declared side is
546
+ * evaluated independently from the target's own before/after geometry;
547
+ * `unavailable` when either side's required property evidence cannot be
548
+ * read, `contradictory-to-declaration` when either side's actual direction
549
+ * is the strict opposite of what was declared, `consistent` when both sides
550
+ * match their declared direction, `not-observed` otherwise. No causal claim
551
+ * is ever produced.
552
+ */
553
+ export function evaluateExpectedDependencies(before, after, declarations, tolerance) {
554
+ return declarations.map((declaration) => {
555
+ const causeBefore = extractTargetProperty(before, declaration.cause.target, declaration.cause.property);
556
+ const causeAfter = extractTargetProperty(after, declaration.cause.target, declaration.cause.property);
557
+ const effectBefore = extractTargetProperty(before, declaration.effect.target, declaration.effect.property);
558
+ const effectAfter = extractTargetProperty(after, declaration.effect.target, declaration.effect.property);
559
+ const supportingEvidence = [
560
+ { path: `targetEvidence.${declaration.cause.target}.geometry` },
561
+ { path: `targetEvidence.${declaration.effect.target}.geometry` },
562
+ ];
563
+ if (causeBefore === undefined || causeAfter === undefined || effectBefore === undefined || effectAfter === undefined) {
564
+ return { declaration, outcome: 'unavailable', supportingEvidence };
565
+ }
566
+ const causeEval = evaluateTerm(causeBefore, causeAfter, declaration.cause.direction, tolerance);
567
+ const effectEval = evaluateTerm(effectBefore, effectAfter, declaration.effect.direction, tolerance);
568
+ let outcome;
569
+ if (causeEval.contradicts || effectEval.contradicts) {
570
+ outcome = 'contradictory-to-declaration';
571
+ }
572
+ else if (causeEval.matches && effectEval.matches) {
573
+ outcome = 'consistent';
574
+ }
575
+ else {
576
+ outcome = 'not-observed';
577
+ }
578
+ return { declaration, outcome, supportingEvidence };
579
+ });
580
+ }
581
+ // --- comparison identity / source references ----------------------------------
582
+ function sourceReference(observation) {
583
+ const screenshot = evidenceValue(observation.screenshot);
584
+ if (!screenshot)
585
+ return undefined;
586
+ return {
587
+ observationId: observation.observationId,
588
+ requestId: observation.requestId,
589
+ producer: observation.producer,
590
+ observationSchemaVersion: observation.schemaVersion,
591
+ screenshot: { path: screenshot.path },
592
+ };
593
+ }
594
+ function normalizeConfig(config) {
595
+ return {
596
+ geometryTolerancePx: config.geometryTolerancePx ?? GEOMETRY_TOLERANCE_DEFAULT_PX,
597
+ ...(config.expectedDependencies !== undefined ? { expectedDependencies: config.expectedDependencies } : {}),
598
+ };
599
+ }
600
+ /**
601
+ * The one canonical pure comparison entry point (real Chromium ->
602
+ * ObservationArtifact -> relationship derivation -> **comparability ->
603
+ * comparison** -> ComparisonArtifact, see docs/ARCHITECTURE.md). Comparison
604
+ * is ordered: `compareObservations(a, b)` and `compareObservations(b, a)`
605
+ * produce different `comparisonRequestId`s and reversed-sign
606
+ * moved/resized deltas - `before`/`after` are never treated as an
607
+ * unordered pair.
608
+ */
609
+ export function compareObservations(before, after, configInput = {}) {
610
+ const beforeValidation = isValidObservationArtifact(before);
611
+ if (!beforeValidation.valid)
612
+ return { ok: false, reason: `before ObservationArtifact is invalid: ${beforeValidation.reason}` };
613
+ const afterValidation = isValidObservationArtifact(after);
614
+ if (!afterValidation.valid)
615
+ return { ok: false, reason: `after ObservationArtifact is invalid: ${afterValidation.reason}` };
616
+ const config = normalizeConfig(configInput);
617
+ if (!isValidComparisonConfig(config))
618
+ return { ok: false, reason: 'ComparisonConfig is invalid' };
619
+ if (!isValidGeometryTolerancePx(config.geometryTolerancePx))
620
+ return { ok: false, reason: 'ComparisonConfig.geometryTolerancePx is out of range' };
621
+ const beforeRef = sourceReference(before);
622
+ const afterRef = sourceReference(after);
623
+ if (!beforeRef)
624
+ return { ok: false, reason: 'before ObservationArtifact has no available screenshot evidence; cannot build a comparison source reference' };
625
+ if (!afterRef)
626
+ return { ok: false, reason: 'after ObservationArtifact has no available screenshot evidence; cannot build a comparison source reference' };
627
+ const tolerance = config.geometryTolerancePx;
628
+ const beforeRelResult = deriveLayoutRelationships(before, { geometryTolerancePx: tolerance });
629
+ if (!beforeRelResult.ok)
630
+ return { ok: false, reason: `relationship derivation failed for the before observation: ${beforeRelResult.reason}` };
631
+ const afterRelResult = deriveLayoutRelationships(after, { geometryTolerancePx: tolerance });
632
+ if (!afterRelResult.ok)
633
+ return { ok: false, reason: `relationship derivation failed for the after observation: ${afterRelResult.reason}` };
634
+ const relationshipsBefore = beforeRelResult.graph;
635
+ const relationshipsAfter = afterRelResult.graph;
636
+ const comparability = evaluateComparability(before, after);
637
+ const configurationChanges = compareTargetConfiguration(before, after);
638
+ const comparisonRequestId = buildComparisonRequestIdentity(before.observationId, after.observationId, config);
639
+ const comparisonId = buildComparisonIdentity(comparisonRequestId);
640
+ const shared = {
641
+ artifactKind: COMPARISON_ARTIFACT_KIND,
642
+ schemaVersion: COMPARISON_SCHEMA_VERSION,
643
+ comparisonId,
644
+ comparisonRequestId,
645
+ producer: getProducerInfo(),
646
+ provenance: { comparedAt: new Date().toISOString() },
647
+ before: beforeRef,
648
+ after: afterRef,
649
+ config,
650
+ comparability,
651
+ configurationChanges,
652
+ relationshipsBefore,
653
+ relationshipsAfter,
654
+ limits: { truncated: false, omittedFields: [], omittedTargetPairs: [] },
655
+ };
656
+ if (comparability.state === 'incomparable') {
657
+ const artifact = {
658
+ ...shared,
659
+ differences: [],
660
+ relationshipChanges: [],
661
+ expectedDependencyEvidence: [],
662
+ diagnostics: [],
663
+ };
664
+ const validation = isValidComparisonArtifact(artifact);
665
+ if (!validation.valid)
666
+ return { ok: false, reason: `assembled incomparable ComparisonArtifact failed structural validation: ${validation.reason}` };
667
+ return { ok: true, artifact };
668
+ }
669
+ const diagnostics = [];
670
+ const common = commonTargets(before, after);
671
+ const differences = [];
672
+ for (const target of common) {
673
+ differences.push(...compareOneTarget(before, after, target, tolerance, diagnostics));
674
+ }
675
+ const matched = common.filter((target) => targetPresence(before.targetEvidence[target.beforeName]) === 'matched' && targetPresence(after.targetEvidence[target.afterName]) === 'matched');
676
+ differences.push(...compareContainment(before, after, matched));
677
+ differences.push(...comparePageSize(before, after, tolerance));
678
+ differences.push(...compareScrollOwner(before, after));
679
+ const relationshipChanges = deriveRelationshipChanges(relationshipsBefore, relationshipsAfter, matched);
680
+ differences.push(...relationshipChangesToDifferences(relationshipChanges, before.observationId, after.observationId));
681
+ const expectedDependencyEvidence = evaluateExpectedDependencies(before, after, config.expectedDependencies ?? [], tolerance);
682
+ const artifact = {
683
+ ...shared,
684
+ differences,
685
+ relationshipChanges,
686
+ expectedDependencyEvidence,
687
+ diagnostics,
688
+ };
689
+ const validation = isValidComparisonArtifact(artifact);
690
+ if (!validation.valid)
691
+ return { ok: false, reason: `assembled ComparisonArtifact failed structural validation: ${validation.reason}` };
692
+ return { ok: true, artifact };
693
+ }
694
+ //# sourceMappingURL=comparisonEngine.js.map