@tractiontactics/tt-fidelity 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/run.mjs ADDED
@@ -0,0 +1,433 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { BANDS, GROUPS, ALL_PROPS } from './constants.mjs';
4
+ import { openPage, screenshotSection } from './capture.mjs';
5
+ import { inPageSections, alignSections } from './sections.mjs';
6
+ import { compareSectionPngs, sectionFailsGate } from './pixel.mjs';
7
+ import {
8
+ loadRoles,
9
+ filterRolesByScope,
10
+ inPageCapture,
11
+ diffStyles,
12
+ assessCoverage,
13
+ renderCoverageFailure
14
+ } from './styles.mjs';
15
+ import { resolveRenderedFonts, suppressFalseFontFamilyRows } from './fonts.mjs';
16
+ import { buildPlan, renderPixelReport, renderQueueMd, setRoleOrder, renderStyleEvidence } from './plan.mjs';
17
+ import { clusterSections } from './rank.mjs';
18
+ import { buildFidelityDocument } from './schema.mjs';
19
+
20
+ /**
21
+ * @typedef {object} FidelityOptions
22
+ * @property {string} [proto]
23
+ * @property {string} [draft]
24
+ * @property {Array<{id?: string, proto: string, draft: string}>} [pages]
25
+ * @property {string} [pagesFile] path to JSON/txt/sitemap list
26
+ * @property {number[]} [viewports]
27
+ * @property {number} [tolerance]
28
+ * @property {string[]} [only]
29
+ * @property {boolean} [menus]
30
+ * @property {string} [auth]
31
+ * @property {string} [draftAuth]
32
+ * @property {string} [cookie]
33
+ * @property {string} [roles]
34
+ * @property {string} [scope]
35
+ * @property {number} [round]
36
+ * @property {number} [maxRounds]
37
+ * @property {string} [json]
38
+ * @property {string} [outDir]
39
+ * @property {'full'|'pixel'|'styles'} [mode]
40
+ * @property {number} [threshold] fail when score >= this (default BANDS.clean)
41
+ * @property {boolean} [cacheBust]
42
+ * @property {number} [concurrency]
43
+ * @property {boolean} [planOnly]
44
+ * @property {boolean} [quiet]
45
+ * @property {boolean} [onlyFailing]
46
+ * @property {string} [priorJson]
47
+ */
48
+
49
+ function log(opts, msg) {
50
+ if (!opts.quiet) console.error(msg);
51
+ }
52
+
53
+ function resolvePages(opts) {
54
+ if (Array.isArray(opts.pages) && opts.pages.length) {
55
+ return opts.pages.map((p, i) => ({
56
+ id: p.id || `page-${i + 1}`,
57
+ proto: p.proto,
58
+ draft: p.draft
59
+ }));
60
+ }
61
+ if (opts.pagesFile) {
62
+ return loadPagesFile(opts.pagesFile, opts);
63
+ }
64
+ if (opts.proto && opts.draft) {
65
+ return [{ id: 'page-1', proto: opts.proto, draft: opts.draft }];
66
+ }
67
+ throw new Error('provide --proto and --draft, or --pages <file>, or pages[]');
68
+ }
69
+
70
+ function loadPagesFile(path, opts) {
71
+ const raw = readFileSync(path, 'utf8');
72
+ if (path.endsWith('.json')) {
73
+ const data = JSON.parse(raw);
74
+ if (Array.isArray(data)) {
75
+ return data.map((p, i) => ({
76
+ id: p.id || `page-${i + 1}`,
77
+ proto: p.proto || p.prototype,
78
+ draft: p.draft
79
+ }));
80
+ }
81
+ throw new Error('--pages JSON must be an array of {id?, proto, draft}');
82
+ }
83
+ // txt: one draft path/url per line; pair with --proto as base or proto|draft per line
84
+ const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
85
+ return lines.map((line, i) => {
86
+ if (line.includes('|')) {
87
+ const [proto, draft] = line.split('|').map((s) => s.trim());
88
+ return { id: `page-${i + 1}`, proto, draft };
89
+ }
90
+ if (!opts.proto) throw new Error('txt --pages with draft-only lines requires --proto as template base');
91
+ return { id: `page-${i + 1}`, proto: opts.proto, draft: line };
92
+ });
93
+ }
94
+
95
+ async function measurePagePair(browser, pageSpec, opts, roles) {
96
+ const viewports = [];
97
+ const mode = opts.mode || 'full';
98
+ const doPixel = mode === 'full' || mode === 'pixel';
99
+ const doStyles = mode === 'full' || mode === 'styles';
100
+ const failThreshold = opts.threshold ?? BANDS.clean;
101
+
102
+ for (const width of opts.viewports) {
103
+ log(opts, ` ${pageSpec.id} @ ${width}px …`);
104
+
105
+ const protoOpen = await openPage(browser, {
106
+ url: pageSpec.proto,
107
+ width,
108
+ auth: opts.auth,
109
+ cacheBust: opts.cacheBust !== false,
110
+ menus: opts.menus
111
+ });
112
+ const draftOpen = await openPage(browser, {
113
+ url: pageSpec.draft,
114
+ width,
115
+ auth: opts.draftAuth || opts.auth,
116
+ cookie: opts.cookie,
117
+ cacheBust: opts.cacheBust !== false,
118
+ menus: opts.menus
119
+ });
120
+
121
+ let styleDiff = { elements: [], global: [], structural: [], total: 0 };
122
+ let coverage = { matched: [], onlyProto: [], onlyDraft: [], absentBoth: [], ratio: 1, unreliable: false };
123
+ let protoCapture = null;
124
+ let draftCapture = null;
125
+
126
+ if (doStyles) {
127
+ protoCapture = await protoOpen.page.evaluate(inPageCapture, { props: ALL_PROPS, roles });
128
+ draftCapture = await draftOpen.page.evaluate(inPageCapture, { props: ALL_PROPS, roles });
129
+ coverage = assessCoverage(protoCapture, draftCapture, roles);
130
+ styleDiff = diffStyles(protoCapture, draftCapture, opts.tolerance, opts.only);
131
+ try {
132
+ const sels = [
133
+ ...new Set([
134
+ ...styleDiff.elements.map((e) => e.protoSelector),
135
+ ...styleDiff.elements.map((e) => e.draftSelector)
136
+ ])
137
+ ].slice(0, 20);
138
+ if (sels.length) {
139
+ const pf = await protoOpen.page.evaluate(resolveRenderedFonts, sels);
140
+ const df = await draftOpen.page.evaluate(resolveRenderedFonts, sels);
141
+ styleDiff.elements = suppressFalseFontFamilyRows(styleDiff.elements, pf, df);
142
+ styleDiff.total = styleDiff.global.length + styleDiff.elements.reduce((n, el) => n + el.rows.length, 0);
143
+ }
144
+ } catch (e) {
145
+ /* font probe optional */
146
+ }
147
+ }
148
+
149
+ const sections = [];
150
+ if (doPixel) {
151
+ const protoSecs = await protoOpen.page.evaluate(inPageSections, { scope: opts.scope });
152
+ const draftSecs = await draftOpen.page.evaluate(inPageSections, { scope: opts.scope });
153
+ const { pairs, structural } = alignSections(protoSecs, draftSecs);
154
+ styleDiff.structural = [...(styleDiff.structural || []), ...structural];
155
+
156
+ const shotsDir = opts.outDir
157
+ ? join(opts.outDir, 'shots', 'sections', pageSpec.id, String(width))
158
+ : null;
159
+ if (shotsDir) mkdirSync(join(shotsDir, 'prototype'), { recursive: true });
160
+ if (shotsDir) mkdirSync(join(shotsDir, 'draft'), { recursive: true });
161
+ if (shotsDir) mkdirSync(join(shotsDir, 'diff'), { recursive: true });
162
+
163
+ for (const pair of pairs) {
164
+ if (opts.onlyFailing && opts.priorClean?.has(`${pageSpec.id}:${width}:${pair.index}`)) {
165
+ sections.push({
166
+ index: pair.index,
167
+ label: pair.draft.label,
168
+ protoSelector: pair.proto.selector,
169
+ draftSelector: pair.draft.selector,
170
+ ttRowId: pair.draft.ttRowId,
171
+ ttBlockIds: pair.draft.ttBlockIds,
172
+ score: 0,
173
+ band: 'clean',
174
+ skipped: true
175
+ });
176
+ continue;
177
+ }
178
+
179
+ const protoPng = await screenshotSection(protoOpen.page, pair.proto);
180
+ const draftPng = await screenshotSection(draftOpen.page, pair.draft);
181
+ const cmp = compareSectionPngs(protoPng, draftPng, {
182
+ includeDiff: Boolean(shotsDir),
183
+ metric: opts.metric || 'hybrid',
184
+ threshold: Number.isFinite(opts.aaThreshold) ? opts.aaThreshold : undefined
185
+ });
186
+
187
+ const shotPaths = {};
188
+ if (shotsDir) {
189
+ const base = String(pair.index).padStart(2, '0');
190
+ const pp = join(shotsDir, 'prototype', `${base}.png`);
191
+ const dp = join(shotsDir, 'draft', `${base}.png`);
192
+ const dif = join(shotsDir, 'diff', `${base}.png`);
193
+ writeFileSync(pp, protoPng);
194
+ writeFileSync(dp, draftPng);
195
+ if (cmp.diffPng) writeFileSync(dif, cmp.diffPng);
196
+ shotPaths.proto = pp;
197
+ shotPaths.draft = dp;
198
+ shotPaths.diff = dif;
199
+ }
200
+
201
+ sections.push({
202
+ index: pair.index,
203
+ label: pair.draft.label || pair.proto.label,
204
+ protoSelector: pair.proto.selector,
205
+ draftSelector: pair.draft.selector,
206
+ ttRowId: pair.draft.ttRowId,
207
+ ttBlockIds: pair.draft.ttBlockIds,
208
+ score: cmp.score,
209
+ band: cmp.band,
210
+ metric: cmp.metric,
211
+ pixelRatio: cmp.pixelRatio,
212
+ ssim: cmp.ssim,
213
+ diffPixels: cmp.diffPixels,
214
+ shotPaths
215
+ });
216
+ }
217
+ }
218
+
219
+ await protoOpen.context.close();
220
+ await draftOpen.context.close();
221
+
222
+ const plan = buildPlan(styleDiff, { pixelSections: sections });
223
+ viewports.push({
224
+ width,
225
+ coverage,
226
+ styleDiff,
227
+ sections,
228
+ cacheHeaders: draftOpen.cacheHeaders,
229
+ plan
230
+ });
231
+ }
232
+
233
+ return {
234
+ id: pageSpec.id,
235
+ proto: pageSpec.proto,
236
+ draft: pageSpec.draft,
237
+ viewports
238
+ };
239
+ }
240
+
241
+ function loadPriorClean(priorJson) {
242
+ const set = new Set();
243
+ if (!priorJson || !existsSync(priorJson)) return set;
244
+ try {
245
+ const doc = JSON.parse(readFileSync(priorJson, 'utf8'));
246
+ for (const page of doc.pages || []) {
247
+ for (const vp of page.viewports || []) {
248
+ for (const s of vp.sections || []) {
249
+ if (s.band === 'clean') set.add(`${page.id}:${vp.width}:${s.index}`);
250
+ }
251
+ }
252
+ }
253
+ } catch (e) { /* ignore */ }
254
+ return set;
255
+ }
256
+
257
+ /**
258
+ * Programmatic entry: run a fidelity comparison.
259
+ * @param {FidelityOptions} opts
260
+ * @returns {Promise<{ exitCode: number, summary: object, pages: object[], plan: object[], document: object, text: string }>}
261
+ */
262
+ export async function runFidelity(opts = {}) {
263
+ const options = {
264
+ viewports: [1280, 768, 390],
265
+ tolerance: 1,
266
+ mode: 'full',
267
+ scope: 'full',
268
+ round: 1,
269
+ maxRounds: 6,
270
+ cacheBust: true,
271
+ concurrency: 1,
272
+ threshold: BANDS.clean,
273
+ metric: 'hybrid',
274
+ ...opts
275
+ };
276
+
277
+ let roles;
278
+ try {
279
+ roles = filterRolesByScope(loadRoles(options.roles), options.scope);
280
+ } catch (err) {
281
+ throw Object.assign(new Error(err.message), { exitCode: 2 });
282
+ }
283
+ if (!roles.length) {
284
+ throw Object.assign(new Error('--scope left no roles to measure.'), { exitCode: 2 });
285
+ }
286
+ setRoleOrder(roles);
287
+
288
+ const pagesSpec = resolvePages(options);
289
+ if (options.outDir) mkdirSync(options.outDir, { recursive: true });
290
+
291
+ let chromium;
292
+ try {
293
+ ({ chromium } = await import('playwright'));
294
+ } catch (err) {
295
+ throw Object.assign(
296
+ new Error('Playwright required. Run: npm i -D playwright && npx playwright install chromium'),
297
+ { exitCode: 2 }
298
+ );
299
+ }
300
+
301
+ options.priorClean = options.onlyFailing
302
+ ? loadPriorClean(options.priorJson || (options.outDir && join(options.outDir, 'fidelity.json')))
303
+ : new Set();
304
+
305
+ const browser = await chromium.launch();
306
+ const pages = [];
307
+
308
+ try {
309
+ const conc = Math.max(1, options.concurrency | 0);
310
+ for (let i = 0; i < pagesSpec.length; i += conc) {
311
+ const batch = pagesSpec.slice(i, i + conc);
312
+ const measured = await Promise.all(
313
+ batch.map((p) => measurePagePair(browser, p, options, roles))
314
+ );
315
+ pages.push(...measured);
316
+ }
317
+ } finally {
318
+ await browser.close();
319
+ }
320
+
321
+ // Coverage guard (styles path)
322
+ if (options.mode === 'full' || options.mode === 'styles') {
323
+ for (const page of pages) {
324
+ for (const vp of page.viewports) {
325
+ if (vp.coverage?.unreliable) {
326
+ const text = renderCoverageFailure(vp.coverage, options.roles).join('\n');
327
+ return {
328
+ exitCode: 2,
329
+ summary: { error: 'low_coverage' },
330
+ pages,
331
+ plan: [],
332
+ document: { error: 'low_coverage', pages },
333
+ text
334
+ };
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ const clusters = clusterSections(pages);
341
+ const flatPlan = [];
342
+ for (const page of pages) {
343
+ for (const vp of page.viewports) {
344
+ for (const t of vp.plan?.tasks || []) {
345
+ flatPlan.push({ ...t, pageId: page.id, width: vp.width });
346
+ }
347
+ }
348
+ }
349
+
350
+ const mode = options.mode;
351
+ let dirty = false;
352
+ if (mode === 'styles') {
353
+ dirty = pages.some((p) =>
354
+ p.viewports.some((vp) => vp.styleDiff?.total || vp.styleDiff?.structural?.length)
355
+ );
356
+ } else {
357
+ dirty = pages.some((p) =>
358
+ p.viewports.some((vp) =>
359
+ (vp.sections || []).some((s) => sectionFailsGate(s.score, options.threshold))
360
+ || (vp.styleDiff?.structural || []).some((s) => s.kind === 'count' && s.role === 'section')
361
+ )
362
+ );
363
+ }
364
+
365
+ let exitCode = dirty ? 1 : 0;
366
+ if (dirty && options.round >= options.maxRounds) exitCode = 3;
367
+
368
+ const document = buildFidelityDocument({
369
+ mode,
370
+ threshold: options.threshold,
371
+ round: options.round,
372
+ maxRounds: options.maxRounds,
373
+ pages,
374
+ clusters,
375
+ workPlan: flatPlan,
376
+ exitCode
377
+ });
378
+
379
+ if (options.outDir) {
380
+ writeFileSync(join(options.outDir, 'fidelity.json'), JSON.stringify(document, null, 2));
381
+ writeFileSync(join(options.outDir, 'QUEUE.md'), renderQueueMd(clusters, pages));
382
+ log(options, `wrote ${join(options.outDir, 'fidelity.json')}`);
383
+ }
384
+ if (options.json) {
385
+ writeFileSync(options.json, JSON.stringify(document, null, 2));
386
+ log(options, `wrote ${options.json}`);
387
+ }
388
+
389
+ let text = '';
390
+ if (mode === 'styles') {
391
+ const styleResults = pages.flatMap((p) =>
392
+ p.viewports.map((vp) => ({
393
+ width: vp.width,
394
+ coverage: vp.coverage,
395
+ diff: vp.styleDiff,
396
+ pixelSections: []
397
+ }))
398
+ );
399
+ text = renderStyleEvidence(styleResults, options.tolerance, options.planOnly);
400
+ } else {
401
+ text = renderPixelReport(pages, { mode });
402
+ if (mode === 'full' && !options.planOnly) {
403
+ const styleResults = pages.flatMap((p) =>
404
+ p.viewports.map((vp) => ({
405
+ width: vp.width,
406
+ coverage: vp.coverage,
407
+ diff: vp.styleDiff,
408
+ pixelSections: vp.sections
409
+ }))
410
+ );
411
+ text += '\n\n' + renderStyleEvidence(styleResults, options.tolerance, true);
412
+ }
413
+ }
414
+
415
+ if (options.round > 1 || options.maxRounds !== 6) {
416
+ text += `\n\nFIDELITY LOOP: round ${options.round} of ${options.maxRounds}.`;
417
+ }
418
+ if (exitCode === 3) {
419
+ text += `\n\nMAX ROUNDS REACHED (${options.round}/${options.maxRounds}) — STOP LOOPING`;
420
+ }
421
+
422
+ return {
423
+ exitCode,
424
+ summary: document.summary,
425
+ pages,
426
+ plan: flatPlan,
427
+ clusters,
428
+ document,
429
+ text
430
+ };
431
+ }
432
+
433
+ export { GROUPS, BANDS, ALL_PROPS };
package/src/schema.mjs ADDED
@@ -0,0 +1,62 @@
1
+ import { SCHEMA_VERSION, PACKAGE_VERSION, BANDS } from './constants.mjs';
2
+
3
+ /**
4
+ * Build fidelity.json v2 document.
5
+ */
6
+ export function buildFidelityDocument({
7
+ mode,
8
+ threshold,
9
+ round,
10
+ maxRounds,
11
+ pages,
12
+ clusters,
13
+ workPlan,
14
+ exitCode
15
+ }) {
16
+ return {
17
+ schema_version: SCHEMA_VERSION,
18
+ package_version: PACKAGE_VERSION,
19
+ mode,
20
+ threshold: {
21
+ clean: threshold ?? BANDS.clean,
22
+ fix: BANDS.fix,
23
+ rebuild: BANDS.fix
24
+ },
25
+ round,
26
+ max_rounds: maxRounds,
27
+ exit_code: exitCode,
28
+ summary: summarize(pages),
29
+ clusters: clusters || [],
30
+ work_plan: workPlan || [],
31
+ pages
32
+ };
33
+ }
34
+
35
+ function summarize(pages) {
36
+ let sections = 0;
37
+ let failing = 0;
38
+ let rebuild = 0;
39
+ for (const page of pages) {
40
+ for (const vp of page.viewports || []) {
41
+ for (const s of vp.sections || []) {
42
+ sections += 1;
43
+ if (s.band !== 'clean') failing += 1;
44
+ if (s.band === 'rebuild') rebuild += 1;
45
+ }
46
+ }
47
+ }
48
+ return {
49
+ pages: pages.length,
50
+ sections,
51
+ failing,
52
+ rebuild,
53
+ clean: sections - failing
54
+ };
55
+ }
56
+
57
+ export function mapTtFromSection(section) {
58
+ return {
59
+ ttRowId: section.ttRowId || null,
60
+ ttBlockIds: Array.isArray(section.ttBlockIds) ? section.ttBlockIds : []
61
+ };
62
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Section discovery for pixel grading.
3
+ * Draft (TT): prefer .tt-pb-row / data-tt-row.
4
+ * Prototype: prefer main > section, then body sections.
5
+ */
6
+
7
+ /* eslint-disable no-undef */
8
+ export function inPageSections({ scope }) {
9
+ const visible = (el) => {
10
+ if (!el) return false;
11
+ const cs = getComputedStyle(el);
12
+ if (cs.visibility === 'hidden' || cs.display === 'none') return false;
13
+ const r = el.getBoundingClientRect();
14
+ return r.width > 2 && r.height > 2;
15
+ };
16
+
17
+ const selOf = (el) => {
18
+ if (!el) return '';
19
+ if (el.getAttribute('data-tt-row')) return `[data-tt-row="${el.getAttribute('data-tt-row')}"]`;
20
+ if (el.id) return `#${CSS.escape(el.id)}`;
21
+ const tag = el.tagName.toLowerCase();
22
+ const parent = el.parentElement;
23
+ if (!parent) return tag;
24
+ const sibs = [...parent.children].filter((c) => c.tagName === el.tagName);
25
+ const idx = sibs.indexOf(el) + 1;
26
+ return `${tag}:nth-of-type(${idx})`;
27
+ };
28
+
29
+ const heading = (el) => {
30
+ const h = el.querySelector('h1,h2,h3,h4,[class*=headline i],[class*=heading i]');
31
+ return h ? (h.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80) : '';
32
+ };
33
+
34
+ const box = (el) => {
35
+ const r = el.getBoundingClientRect();
36
+ const top = Math.round(r.top + window.scrollY);
37
+ return {
38
+ x: Math.round(r.left),
39
+ y: top,
40
+ width: Math.round(r.width),
41
+ height: Math.round(r.height),
42
+ viewportTop: Math.round(r.top),
43
+ viewportLeft: Math.round(r.left)
44
+ };
45
+ };
46
+
47
+ const ttMeta = (el) => {
48
+ const rowId = el.getAttribute('data-tt-row') || (el.id && /^row_/.test(el.id) ? el.id : '');
49
+ const blocks = [...el.querySelectorAll('[data-tt-block], .tt-pb-block[id^="blk_"]')]
50
+ .map((b) => b.getAttribute('data-tt-block') || b.id)
51
+ .filter(Boolean);
52
+ return { ttRowId: rowId || null, ttBlockIds: blocks };
53
+ };
54
+
55
+ let nodes = [];
56
+ const s = String(scope || 'full').toLowerCase();
57
+
58
+ if (s === 'chrome') {
59
+ const chrome = [
60
+ ...document.querySelectorAll('header, [role=banner], .site-header, footer, [role=contentinfo], .site-footer')
61
+ ].filter(visible);
62
+ nodes = chrome;
63
+ } else {
64
+ const ttRows = [...document.querySelectorAll('.tt-pb-row, [data-tt-row]')].filter(visible);
65
+ if (ttRows.length) {
66
+ nodes = ttRows;
67
+ } else {
68
+ const main = document.querySelector('main, [role=main], .site-main') || document.body;
69
+ const sections = [...main.querySelectorAll(':scope > section')].filter(visible);
70
+ if (sections.length) {
71
+ nodes = sections;
72
+ } else {
73
+ nodes = [...main.children].filter(
74
+ (el) => visible(el) && !['SCRIPT', 'STYLE', 'LINK', 'NOSCRIPT'].includes(el.tagName)
75
+ );
76
+ }
77
+ }
78
+ if (s === 'content') {
79
+ nodes = nodes.filter((el) => !el.closest('header, footer, [role=banner], [role=contentinfo]'));
80
+ }
81
+ }
82
+
83
+ return nodes.map((el, i) => {
84
+ const meta = ttMeta(el);
85
+ return {
86
+ index: i,
87
+ selector: selOf(el),
88
+ label: heading(el) || (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 48),
89
+ box: box(el),
90
+ ttRowId: meta.ttRowId,
91
+ ttBlockIds: meta.ttBlockIds
92
+ };
93
+ });
94
+ }
95
+ /* eslint-enable no-undef */
96
+
97
+ /**
98
+ * Align sections by document order. Length mismatch → structural findings.
99
+ */
100
+ export function alignSections(protoSections, draftSections) {
101
+ const n = Math.min(protoSections.length, draftSections.length);
102
+ const pairs = [];
103
+ for (let i = 0; i < n; i += 1) {
104
+ pairs.push({ index: i, proto: protoSections[i], draft: draftSections[i] });
105
+ }
106
+ const structural = [];
107
+ if (protoSections.length !== draftSections.length) {
108
+ structural.push({
109
+ kind: 'count',
110
+ role: 'section',
111
+ text: `section: prototype has ${protoSections.length}, draft has ${draftSections.length}${
112
+ draftSections.length < protoSections.length
113
+ ? ` (draft is missing ${protoSections.length - draftSections.length})`
114
+ : ` (draft has ${draftSections.length - protoSections.length} extra)`
115
+ }`
116
+ });
117
+ }
118
+ return { pairs, structural };
119
+ }