@tractiontactics/tt-fidelity 0.2.1 → 0.2.2
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/AGENTS.md +11 -3
- package/README.md +6 -3
- package/fidelity.schema.json +8 -1
- package/fixtures/self-test.mjs +22 -0
- package/package.json +4 -1
- package/src/cli.mjs +4 -3
- package/src/constants.mjs +1 -1
- package/src/plan.mjs +38 -2
- package/src/run.mjs +6 -3
- package/src/sections.mjs +22 -6
package/AGENTS.md
CHANGED
|
@@ -11,6 +11,9 @@ You are measuring a prototype against a TT WP Theme draft. **You are not the gra
|
|
|
11
11
|
5. **Never apply a rule site-wide** until `instances_per_page` (in clusters / QUEUE) shows it is a real default. Blanket CSS from a rare seam creates hundreds of false findings.
|
|
12
12
|
6. **Cache:** the tool cache-busts by default. If draft and live disagree, purge host/CDN and re-run — do not “fix” ghosts.
|
|
13
13
|
7. **Escalate** tokens → block Style/Advanced → minimal `PUT /custom-css` → theme change → HTML template. Cite `ttRowId` / `ttBlockIds` when present.
|
|
14
|
+
8. **Default `--scope content`** (page body). Use `--scope chrome` for header/footer. Use `--scope full` only when you intentionally want both (TT draft may then count header/footer rows the prototype does not).
|
|
15
|
+
9. **QUEUE lifetime:** `ttRowId` values persist across *renders* of the same layout JSON, but a **republish / rebuild that regenerates row ids** invalidates the QUEUE. Re-measure after any full layout rewrite.
|
|
16
|
+
10. **`--only-failing`:** optional acceleration only. Do **not** grant exit 0 after an only-failing pass — run a full measure (all sections) first.
|
|
14
17
|
|
|
15
18
|
## Loop
|
|
16
19
|
|
|
@@ -21,21 +24,26 @@ npx @tractiontactics/tt-fidelity \
|
|
|
21
24
|
--viewports 1280,768,390 \
|
|
22
25
|
--out ./fidelity \
|
|
23
26
|
--mode full \
|
|
27
|
+
--scope content \
|
|
24
28
|
--round N \
|
|
25
29
|
--max-rounds 6
|
|
26
30
|
```
|
|
27
31
|
|
|
28
32
|
- Exit **0** → page (or chrome scope) done for this URL pair.
|
|
29
|
-
- Exit **1** → do TASK 1 from the plan; re-measure.
|
|
33
|
+
- Exit **1** → do TASK 1 from the plan; re-measure. Section **count** mismatches and **unpaired** sections also keep exit 1.
|
|
30
34
|
- Exit **2** → fix install / auth / `--roles`; do not guess missing sections.
|
|
31
35
|
- Exit **3** → stop; report residuals; consider history restore.
|
|
32
36
|
|
|
37
|
+
## Structural noise
|
|
38
|
+
|
|
39
|
+
If a STRUCTURAL task claims the draft has “extra sections” and those are clearly header/footer chrome, re-run with `--scope content` (default since 0.2.2). Do not `PUT /pages/.../layout` to “add” chrome rows.
|
|
40
|
+
|
|
33
41
|
## Large sites
|
|
34
42
|
|
|
35
|
-
Compare one prototype page to its draft at a time, or pass `--pages`. Use `--scope chrome` then `--scope content`. Prefer `--only-failing`
|
|
43
|
+
Compare one prototype page to its draft at a time, or pass `--pages`. Use `--scope chrome` then `--scope content`. Prefer `--only-failing` only mid-loop; finish with a full pass.
|
|
36
44
|
|
|
37
45
|
## Forbidden
|
|
38
46
|
|
|
39
47
|
- Certifying “100% parity” without exit 0 from the pixel gate.
|
|
40
48
|
- Substituting fetch-only CSS analysis for the main path.
|
|
41
|
-
- Ignoring section count mismatches (structural first).
|
|
49
|
+
- Ignoring unpaired / section count mismatches (structural first — after confirming scope).
|
package/README.md
CHANGED
|
@@ -12,9 +12,12 @@ npx @tractiontactics/tt-fidelity \
|
|
|
12
12
|
--draft "https://site.example.com/?p=12&preview=true" \
|
|
13
13
|
--viewports 1280,768,390 \
|
|
14
14
|
--out ./fidelity \
|
|
15
|
-
--mode full
|
|
15
|
+
--mode full \
|
|
16
|
+
--scope content
|
|
16
17
|
```
|
|
17
18
|
|
|
19
|
+
Default `--scope` is **`content`** (page body; excludes TT header/footer rows). Use `--scope chrome` for header/footer, `--scope full` only when you need both.
|
|
20
|
+
|
|
18
21
|
Monorepo:
|
|
19
22
|
|
|
20
23
|
```bash
|
|
@@ -97,10 +100,10 @@ Override fail cut with `--threshold` (default `0.10`).
|
|
|
97
100
|
| `--out <dir>` | `fidelity.json`, `QUEUE.md`, `shots/sections/…` |
|
|
98
101
|
| `--mode full\|pixel\|styles` | See above |
|
|
99
102
|
| `--viewports` | Default `1280,768,390` |
|
|
100
|
-
| `--scope
|
|
103
|
+
| `--scope` | Default **`content`**. `chrome` = header/footer. `full` = both (may skew TT section counts). |
|
|
101
104
|
| `--cache-bust` / `--no-cache-bust` | Default **on** (`tt_nocache=`); logs cache headers |
|
|
102
105
|
| `--concurrency <n>` | Parallel pages |
|
|
103
|
-
| `--only-failing` | Skip sections clean in prior `fidelity.json` |
|
|
106
|
+
| `--only-failing` | Skip sections clean in prior `fidelity.json` — finish with a full pass before exit 0 |
|
|
104
107
|
| `--roles` | Landmark map when defaults miss |
|
|
105
108
|
| `--round` / `--max-rounds` | Agent loop |
|
|
106
109
|
| `--json` | Extra copy of the document |
|
package/fidelity.schema.json
CHANGED
|
@@ -66,7 +66,14 @@
|
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
"cacheHeaders": { "type": "object" },
|
|
69
|
-
"coverage": { "type": "object" }
|
|
69
|
+
"coverage": { "type": "object" },
|
|
70
|
+
"unpaired": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"prototype": { "type": "array" },
|
|
74
|
+
"draft": { "type": "array" }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
79
|
}
|
package/fixtures/self-test.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { runFidelity } from '../src/index.mjs';
|
|
11
|
+
import { alignSections } from '../src/sections.mjs';
|
|
12
|
+
import { PACKAGE_VERSION } from '../src/constants.mjs';
|
|
11
13
|
|
|
12
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const FIX = __dirname;
|
|
@@ -24,6 +26,26 @@ function assert(cond, msg) {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
async function main() {
|
|
29
|
+
assert(PACKAGE_VERSION === '0.2.2', 'package version constant is 0.2.2');
|
|
30
|
+
|
|
31
|
+
// Align: unpaired + structural when counts differ
|
|
32
|
+
{
|
|
33
|
+
const proto = [
|
|
34
|
+
{ index: 0, label: 'A', selector: '#a', ttRowId: null },
|
|
35
|
+
{ index: 1, label: 'B', selector: '#b', ttRowId: null },
|
|
36
|
+
{ index: 2, label: 'C-unpaired', selector: '#c', ttRowId: null }
|
|
37
|
+
];
|
|
38
|
+
const draft = [
|
|
39
|
+
{ index: 0, label: 'A', selector: '#a', ttRowId: 'row_a' },
|
|
40
|
+
{ index: 1, label: 'B', selector: '#b', ttRowId: 'row_b' }
|
|
41
|
+
];
|
|
42
|
+
const { pairs, structural, unpaired } = alignSections(proto, draft);
|
|
43
|
+
assert(pairs.length === 2, 'align: pairs = min length');
|
|
44
|
+
assert(unpaired.prototype.length === 1 && unpaired.prototype[0].label === 'C-unpaired', 'align: unpaired proto listed');
|
|
45
|
+
assert(unpaired.draft.length === 0, 'align: no unpaired draft');
|
|
46
|
+
assert(structural.some((s) => s.kind === 'count'), 'align: count structural finding');
|
|
47
|
+
}
|
|
48
|
+
|
|
27
49
|
// 1. Known style drift — exit 1 in styles mode
|
|
28
50
|
{
|
|
29
51
|
const r = await runFidelity({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tractiontactics/tt-fidelity",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Section-first visual fidelity: hybrid SSIM+pixel gate + computed-style explainer for TT Platform prototype→site builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -51,5 +51,8 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"pixelmatch": "^7.2.0",
|
|
53
53
|
"pngjs": "^7.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"playwright": "^1.63.0"
|
|
54
57
|
}
|
|
55
58
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -13,7 +13,7 @@ function parseArgs(argv) {
|
|
|
13
13
|
only: null,
|
|
14
14
|
menus: false,
|
|
15
15
|
quiet: false,
|
|
16
|
-
scope: '
|
|
16
|
+
scope: 'content',
|
|
17
17
|
round: 1,
|
|
18
18
|
maxRounds: 6,
|
|
19
19
|
mode: 'full',
|
|
@@ -77,11 +77,12 @@ function usage() {
|
|
|
77
77
|
--auth u:p / --draft-auth basic auth
|
|
78
78
|
--cookie "n=v" draft cookies
|
|
79
79
|
--roles roles.json project role map
|
|
80
|
-
--scope
|
|
80
|
+
--scope content|chrome|full default content (page body; excludes TT header/footer rows)
|
|
81
|
+
use chrome for header/footer; full only when you need both
|
|
81
82
|
--round / --max-rounds fidelity loop (exit 3 at max with diffs)
|
|
82
83
|
--cache-bust / --no-cache-bust default ON (tt_nocache=)
|
|
83
84
|
--concurrency <n> parallel pages (default 1)
|
|
84
|
-
--only-failing skip sections clean in prior fidelity.json
|
|
85
|
+
--only-failing skip sections clean in prior fidelity.json (do not use until a final full pass)
|
|
85
86
|
--prior <path> prior fidelity.json for --only-failing
|
|
86
87
|
--json out.json also write document
|
|
87
88
|
--plan-only suppress raw evidence tables
|
package/src/constants.mjs
CHANGED
package/src/plan.mjs
CHANGED
|
@@ -121,6 +121,7 @@ export function buildPlan(d, { pixelSections = [] } = {}) {
|
|
|
121
121
|
}
|
|
122
122
|
for (const [role, items] of structuralByRole) {
|
|
123
123
|
const unmatched = items.some((i) => i.kind === 'unmatched' || i.kind === 'extra');
|
|
124
|
+
const hints = items.map((i) => i.hint).filter(Boolean);
|
|
124
125
|
tasks.push({
|
|
125
126
|
kind: 'structural',
|
|
126
127
|
title: unmatched
|
|
@@ -129,13 +130,34 @@ export function buildPlan(d, { pixelSections = [] } = {}) {
|
|
|
129
130
|
why: items.map((i) => i.text),
|
|
130
131
|
fixAt: unmatched
|
|
131
132
|
? `FIRST verify it is really absent (inspect the page). If it exists under different markup, add its selector via --roles and re-run — do not "fix" it. If genuinely absent: ${fixHintFor(null, role, 'structural')}`
|
|
132
|
-
: fixHintFor(null, role, 'structural'),
|
|
133
|
+
: (hints[0] || fixHintFor(null, role, 'structural')),
|
|
133
134
|
rows: [],
|
|
134
135
|
affects: items.length,
|
|
135
136
|
mayResolve: 0
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
const unpaired = d.unpaired;
|
|
141
|
+
if (unpaired && ((unpaired.prototype && unpaired.prototype.length) || (unpaired.draft && unpaired.draft.length))) {
|
|
142
|
+
const why = [];
|
|
143
|
+
for (const u of unpaired.prototype || []) {
|
|
144
|
+
why.push(`unpaired prototype [${u.index}]: "${u.label || u.selector}"${u.ttRowId ? ` (${u.ttRowId})` : ''}`);
|
|
145
|
+
}
|
|
146
|
+
for (const u of unpaired.draft || []) {
|
|
147
|
+
why.push(`unpaired draft [${u.index}]: "${u.label || u.selector}"${u.ttRowId ? ` (${u.ttRowId})` : ''}`);
|
|
148
|
+
}
|
|
149
|
+
tasks.push({
|
|
150
|
+
kind: 'structural',
|
|
151
|
+
title: 'Unpaired sections — not pixel-compared (positional truncate)',
|
|
152
|
+
why,
|
|
153
|
+
fixAt:
|
|
154
|
+
'These sections were dropped from the pixel gate because counts differ. Prefer --scope content for page body (default since 0.2.2). Fix real missing/extra content, then re-measure so every section is paired.',
|
|
155
|
+
rows: [],
|
|
156
|
+
affects: why.length,
|
|
157
|
+
mayResolve: 0
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
139
161
|
// Pixel sections — severity-ranked, before global style (rebuild first).
|
|
140
162
|
const failing = (pixelSections || [])
|
|
141
163
|
.filter((s) => s.band && s.band !== 'clean')
|
|
@@ -381,6 +403,10 @@ export function renderQueueMd(clusters, pages) {
|
|
|
381
403
|
lines.push('');
|
|
382
404
|
lines.push('Ranked by leverage. Do one task, re-measure, continue.');
|
|
383
405
|
lines.push('');
|
|
406
|
+
lines.push('> Default scope is **content** (page body). Use `--scope chrome` for header/footer.');
|
|
407
|
+
lines.push('> This QUEUE is valid only until the draft page layout is rewritten (row ids may change).');
|
|
408
|
+
lines.push('> Do not use `--only-failing` until a final full pass before claiming exit 0.');
|
|
409
|
+
lines.push('');
|
|
384
410
|
if (clusters?.length) {
|
|
385
411
|
lines.push('## Cross-page clusters (fix these first)');
|
|
386
412
|
lines.push('');
|
|
@@ -397,13 +423,23 @@ export function renderQueueMd(clusters, pages) {
|
|
|
397
423
|
for (const page of pages) {
|
|
398
424
|
let fail = 0;
|
|
399
425
|
let total = 0;
|
|
426
|
+
const unpairedNotes = [];
|
|
400
427
|
for (const vp of page.viewports) {
|
|
401
428
|
for (const s of vp.sections || []) {
|
|
402
429
|
total += 1;
|
|
403
430
|
if (s.band !== 'clean') fail += 1;
|
|
404
431
|
}
|
|
432
|
+
const u = vp.unpaired || vp.styleDiff?.unpaired;
|
|
433
|
+
if (u && (u.prototype?.length || u.draft?.length)) {
|
|
434
|
+
unpairedNotes.push(
|
|
435
|
+
`${vp.width}px: ${u.prototype?.length || 0} proto / ${u.draft?.length || 0} draft unpaired`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
lines.push(`- \`${page.id}\`: ${fail}/${total} sections failing (compared)`);
|
|
440
|
+
for (const note of unpairedNotes) {
|
|
441
|
+
lines.push(` - unpaired (not pixel-compared): ${note}`);
|
|
405
442
|
}
|
|
406
|
-
lines.push(`- \`${page.id}\`: ${fail}/${total} sections failing`);
|
|
407
443
|
}
|
|
408
444
|
lines.push('');
|
|
409
445
|
return lines.join('\n');
|
package/src/run.mjs
CHANGED
|
@@ -150,9 +150,11 @@ async function measurePagePair(browser, pageSpec, opts, roles) {
|
|
|
150
150
|
if (doPixel) {
|
|
151
151
|
const protoSecs = await protoOpen.page.evaluate(inPageSections, { scope: opts.scope });
|
|
152
152
|
const draftSecs = await draftOpen.page.evaluate(inPageSections, { scope: opts.scope });
|
|
153
|
-
const { pairs, structural } = alignSections(protoSecs, draftSecs);
|
|
153
|
+
const { pairs, structural, unpaired } = alignSections(protoSecs, draftSecs);
|
|
154
154
|
styleDiff.structural = [...(styleDiff.structural || []), ...structural];
|
|
155
|
-
|
|
155
|
+
if (unpaired?.prototype?.length || unpaired?.draft?.length) {
|
|
156
|
+
styleDiff.unpaired = unpaired;
|
|
157
|
+
}
|
|
156
158
|
const shotsDir = opts.outDir
|
|
157
159
|
? join(opts.outDir, 'shots', 'sections', pageSpec.id, String(width))
|
|
158
160
|
: null;
|
|
@@ -225,6 +227,7 @@ async function measurePagePair(browser, pageSpec, opts, roles) {
|
|
|
225
227
|
coverage,
|
|
226
228
|
styleDiff,
|
|
227
229
|
sections,
|
|
230
|
+
unpaired: styleDiff.unpaired || { prototype: [], draft: [] },
|
|
228
231
|
cacheHeaders: draftOpen.cacheHeaders,
|
|
229
232
|
plan
|
|
230
233
|
});
|
|
@@ -264,7 +267,7 @@ export async function runFidelity(opts = {}) {
|
|
|
264
267
|
viewports: [1280, 768, 390],
|
|
265
268
|
tolerance: 1,
|
|
266
269
|
mode: 'full',
|
|
267
|
-
scope: '
|
|
270
|
+
scope: 'content',
|
|
268
271
|
round: 1,
|
|
269
272
|
maxRounds: 6,
|
|
270
273
|
cacheBust: true,
|
package/src/sections.mjs
CHANGED
|
@@ -95,7 +95,7 @@ export function inPageSections({ scope }) {
|
|
|
95
95
|
/* eslint-enable no-undef */
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Align sections by document order. Length mismatch → structural findings.
|
|
98
|
+
* Align sections by document order. Length mismatch → structural findings + unpaired lists.
|
|
99
99
|
*/
|
|
100
100
|
export function alignSections(protoSections, draftSections) {
|
|
101
101
|
const n = Math.min(protoSections.length, draftSections.length);
|
|
@@ -103,17 +103,33 @@ export function alignSections(protoSections, draftSections) {
|
|
|
103
103
|
for (let i = 0; i < n; i += 1) {
|
|
104
104
|
pairs.push({ index: i, proto: protoSections[i], draft: draftSections[i] });
|
|
105
105
|
}
|
|
106
|
+
const unpaired = {
|
|
107
|
+
prototype: protoSections.slice(n).map(summarizeUnpaired),
|
|
108
|
+
draft: draftSections.slice(n).map(summarizeUnpaired)
|
|
109
|
+
};
|
|
106
110
|
const structural = [];
|
|
107
111
|
if (protoSections.length !== draftSections.length) {
|
|
112
|
+
const missing = protoSections.length - draftSections.length;
|
|
108
113
|
structural.push({
|
|
109
114
|
kind: 'count',
|
|
110
115
|
role: 'section',
|
|
111
116
|
text: `section: prototype has ${protoSections.length}, draft has ${draftSections.length}${
|
|
112
|
-
|
|
113
|
-
? ` (draft is missing ${
|
|
114
|
-
: ` (draft has ${
|
|
115
|
-
}
|
|
117
|
+
missing > 0
|
|
118
|
+
? ` (draft is missing ${missing})`
|
|
119
|
+
: ` (draft has ${-missing} extra)`
|
|
120
|
+
}`,
|
|
121
|
+
hint:
|
|
122
|
+
'If comparing page body only, re-run with --scope content (default since 0.2.2) so TT header/footer rows are not counted as extra sections. Chromeless count gaps may still be real layout issues.'
|
|
116
123
|
});
|
|
117
124
|
}
|
|
118
|
-
return { pairs, structural };
|
|
125
|
+
return { pairs, structural, unpaired };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function summarizeUnpaired(sec) {
|
|
129
|
+
return {
|
|
130
|
+
index: sec.index,
|
|
131
|
+
label: sec.label || '',
|
|
132
|
+
selector: sec.selector || '',
|
|
133
|
+
ttRowId: sec.ttRowId || null
|
|
134
|
+
};
|
|
119
135
|
}
|