@tokenoftrust/cli 2.0.24 → 2.0.26
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/package.json +4 -1
- package/src/commands/revert.mjs +44 -30
- package/src/commands/ship.mjs +82 -26
- package/src/commands/validate.mjs +21 -3
- package/src/validate.mjs +2 -2
- package/src/visual-parity.mjs +321 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.26",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"access": "public",
|
|
37
37
|
"registry": "https://registry.npmjs.org"
|
|
38
38
|
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"sharp": "^0.34.5"
|
|
41
|
+
},
|
|
39
42
|
"devDependencies": {
|
|
40
43
|
"@types/node": "^20.16.0",
|
|
41
44
|
"typescript": "^5.6.3"
|
package/src/commands/revert.mjs
CHANGED
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
*
|
|
25
25
|
* TRANSPORT — `POST /api/changes/revert` (opens the corrective candidate, then
|
|
26
26
|
* runs it through the SAME `TenantIntegrationQueue.enqueue` accept pipeline
|
|
27
|
-
* `tot accept` drives). Like `tot accept
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
* `tot accept` drives). Like `tot accept`, the CLI reaches it with either the
|
|
28
|
+
* operator-secret Bearer transport (`resolveOperatorSecret` + `X-Tot-Owner` +
|
|
29
|
+
* `x-tot-capability`) or a signed-in developer's viewer session when they hold
|
|
30
|
+
* a live `ship-on-behalf` grant. The response is the honest terminal aggregate
|
|
31
|
+
* state — `queueState` / `runState` / `pointerMoved` / `aggregateSha` /
|
|
32
|
+
* `changeId` (the corrective candidate's own id) / `statusMessage` — rendered
|
|
33
|
+
* VERBATIM, never a bare "reverted".
|
|
33
34
|
*
|
|
34
35
|
* HUMAN GATE — correcting the shared preview is a decision a human makes, so
|
|
35
36
|
* this ALWAYS states the EXACT plan (a NEW corrective candidate, merged through
|
|
@@ -44,6 +45,9 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
|
44
45
|
// Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + revert
|
|
45
46
|
// speak ONE operator-auth contract.
|
|
46
47
|
import { resolveOperatorSecret } from "./ship.mjs";
|
|
48
|
+
// No-operator-secret path: mint a viewer session from the developer's OWN `tot`
|
|
49
|
+
// login. The server still gates the request on their live ship-on-behalf grant.
|
|
50
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
47
51
|
|
|
48
52
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
49
53
|
|
|
@@ -97,6 +101,9 @@ Options:
|
|
|
97
101
|
|
|
98
102
|
To undo something already LIVE, use \`tot rollback\` — not \`tot revert\`.
|
|
99
103
|
|
|
104
|
+
Authentication: a signed-in developer with a live \`ship-on-behalf\` grant can
|
|
105
|
+
use their own \`tot login\` session. Operators/CI may instead provide a secret.
|
|
106
|
+
|
|
100
107
|
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
101
108
|
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
|
|
102
109
|
|
|
@@ -212,22 +219,25 @@ export function reportReverted(result, { tenant, label }) {
|
|
|
212
219
|
|
|
213
220
|
/**
|
|
214
221
|
* The revert flow after args are parsed: state the exact plan, confirm, then POST
|
|
215
|
-
* `/api/changes/revert`
|
|
216
|
-
*
|
|
217
|
-
* network/TTY.
|
|
222
|
+
* `/api/changes/revert` over either the operator-secret or developer viewer-session
|
|
223
|
+
* transport and render the honest terminal aggregate status. Dependencies are
|
|
224
|
+
* injected so it is unit-tested with no network/TTY.
|
|
218
225
|
*
|
|
219
226
|
* @param {{ tenant:string, prNumber:number|null, changeId:string|null,
|
|
220
|
-
* message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean
|
|
221
|
-
*
|
|
227
|
+
* message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean,
|
|
228
|
+
* env?:NodeJS.ProcessEnv }} params
|
|
229
|
+
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
230
|
+
* resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
222
231
|
* @returns {Promise<number>} process exit code
|
|
223
232
|
*/
|
|
224
233
|
export async function runRevert(
|
|
225
|
-
{ tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false },
|
|
234
|
+
{ tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false, env = process.env },
|
|
226
235
|
deps = {},
|
|
227
236
|
) {
|
|
228
237
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
229
238
|
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
230
|
-
const
|
|
239
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
240
|
+
let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
231
241
|
const label = prNumber != null ? `PR #${prNumber}` : `change ${changeId}`;
|
|
232
242
|
|
|
233
243
|
// 1. State the EXACT plan (shared affordance) — a NEW corrective candidate
|
|
@@ -257,22 +267,25 @@ export async function runRevert(
|
|
|
257
267
|
return 1;
|
|
258
268
|
}
|
|
259
269
|
|
|
260
|
-
// 2.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
// 2. Resolve the transport. Operators/CI use the secret on the generic host;
|
|
271
|
+
// invited developers use their own `tot login` identity on the tenant-scoped
|
|
272
|
+
// host. Both reach the same server route and the same live capability gate.
|
|
273
|
+
let authHeaders;
|
|
274
|
+
if (secret) {
|
|
275
|
+
authHeaders = {
|
|
276
|
+
authorization: `Bearer ${secret}`,
|
|
277
|
+
"x-tot-owner": tenant,
|
|
278
|
+
"x-tot-capability": "ship-on-behalf",
|
|
279
|
+
};
|
|
280
|
+
} else {
|
|
281
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
|
|
282
|
+
if (!viewer.ok) {
|
|
283
|
+
console.error(fail(viewer.message, viewer.hint));
|
|
284
|
+
return 2;
|
|
285
|
+
}
|
|
286
|
+
base = viewer.base;
|
|
287
|
+
authHeaders = viewer.authHeaders;
|
|
269
288
|
}
|
|
270
|
-
const authHeaders = {
|
|
271
|
-
"content-type": "application/json",
|
|
272
|
-
authorization: `Bearer ${secret}`,
|
|
273
|
-
"x-tot-owner": tenant,
|
|
274
|
-
"x-tot-capability": "ship-on-behalf",
|
|
275
|
-
};
|
|
276
289
|
const requestBody = {
|
|
277
290
|
repo: tenant,
|
|
278
291
|
...(prNumber != null ? { prNumber } : {}),
|
|
@@ -285,7 +298,7 @@ export async function runRevert(
|
|
|
285
298
|
try {
|
|
286
299
|
res = await fetchImpl(`${base}/api/changes/revert`, {
|
|
287
300
|
method: "POST",
|
|
288
|
-
headers: authHeaders,
|
|
301
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
289
302
|
body: JSON.stringify(requestBody),
|
|
290
303
|
});
|
|
291
304
|
} catch (e) {
|
|
@@ -311,7 +324,7 @@ export async function runRevert(
|
|
|
311
324
|
fail(
|
|
312
325
|
`the revert request was refused: ${msg}`,
|
|
313
326
|
res.status === 401 || res.status === 403
|
|
314
|
-
? "
|
|
327
|
+
? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
|
|
315
328
|
: "check --tenant / --url / --preview, then re-run",
|
|
316
329
|
),
|
|
317
330
|
);
|
|
@@ -366,5 +379,6 @@ export async function run(argv, ctx) {
|
|
|
366
379
|
secret: resolveOperatorSecret(args.secret, env),
|
|
367
380
|
storefrontUrl,
|
|
368
381
|
yes: args.yes,
|
|
382
|
+
env,
|
|
369
383
|
});
|
|
370
384
|
}
|
package/src/commands/ship.mjs
CHANGED
|
@@ -36,11 +36,9 @@
|
|
|
36
36
|
* `promote_failed` / `record_failed` (a partial failure, recoverable by
|
|
37
37
|
* re-running `tot ship`, never a false live claim).
|
|
38
38
|
*
|
|
39
|
-
* TRANSPORT:
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* `tot ship <N> --tenant` operator-by-PR path used. No MCP client, no git: ship
|
|
43
|
-
* is now pure HTTP (dependency-free — global `fetch` only).
|
|
39
|
+
* TRANSPORT: operators/CI may use a Bearer operator secret. A signed-in owner or
|
|
40
|
+
* developer instead mints a storefront viewer session from their OWN `tot login`
|
|
41
|
+
* identity; the server still enforces the live owner / `ship-on-behalf` grant.
|
|
44
42
|
*/
|
|
45
43
|
import { fail } from "../errors.mjs";
|
|
46
44
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
@@ -52,6 +50,7 @@ import { emitActivity } from "../activity.mjs";
|
|
|
52
50
|
// sees WHICH changes are keeping the aggregate from going green without a separate
|
|
53
51
|
// `tot preview doctor` run.
|
|
54
52
|
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
53
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
55
54
|
|
|
56
55
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
57
56
|
|
|
@@ -62,10 +61,15 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
|
|
|
62
61
|
tot ship --yes confirm non-interactively (skip the [y/N] prompt)
|
|
63
62
|
tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
64
63
|
tot ship --secret <s> operator secret (prefer the env vars below)
|
|
64
|
+
tot ship --accept-quality-findings <reason>
|
|
65
|
+
accept this revision's overridable quality findings,
|
|
66
|
+
record the required reason, then continue publishing
|
|
65
67
|
tot ship --no-open don't open the live URL in your browser
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
Authentication: a signed-in owner/developer with a live ship-on-behalf grant
|
|
70
|
+
can use their own \`tot login\` session. Operators/CI may instead provide a
|
|
71
|
+
secret via PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET,
|
|
72
|
+
TOT_OPERATOR_SECRET, or --secret.
|
|
69
73
|
|
|
70
74
|
\`tot ship\` has ONE meaning: it publishes the tenant's shared preview
|
|
71
75
|
aggregate — the batch of PRs that integrated cleanly and went GREEN — to
|
|
@@ -88,6 +92,7 @@ export function parseShipArgs(argv) {
|
|
|
88
92
|
noOpen: false,
|
|
89
93
|
url: null,
|
|
90
94
|
secret: null,
|
|
95
|
+
acceptQualityReason: null,
|
|
91
96
|
help: false,
|
|
92
97
|
};
|
|
93
98
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -97,6 +102,7 @@ export function parseShipArgs(argv) {
|
|
|
97
102
|
else if (t === "--no-open") a.noOpen = true;
|
|
98
103
|
else if (t === "--url") a.url = argv[++i];
|
|
99
104
|
else if (t === "--secret") a.secret = argv[++i];
|
|
105
|
+
else if (t === "--accept-quality-findings") a.acceptQualityReason = argv[++i];
|
|
100
106
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
101
107
|
}
|
|
102
108
|
return a;
|
|
@@ -305,32 +311,46 @@ async function readJsonSafe(res) {
|
|
|
305
311
|
* injected so it's unit-tested with no live network/TTY.
|
|
306
312
|
*
|
|
307
313
|
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
308
|
-
* yes?:boolean, noOpen?:boolean
|
|
314
|
+
* yes?:boolean, noOpen?:boolean, acceptQualityReason?:string|null,
|
|
315
|
+
* env?:NodeJS.ProcessEnv }} params
|
|
309
316
|
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
310
|
-
* openUrl?:(u:string)=>boolean, progress?:boolean
|
|
317
|
+
* openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
318
|
+
* resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
311
319
|
* @returns {Promise<number>} process exit code
|
|
312
320
|
*/
|
|
313
|
-
export async function runShip(
|
|
321
|
+
export async function runShip(
|
|
322
|
+
{
|
|
323
|
+
tenant,
|
|
324
|
+
secret,
|
|
325
|
+
storefrontUrl = null,
|
|
326
|
+
yes = false,
|
|
327
|
+
noOpen = false,
|
|
328
|
+
acceptQualityReason = null,
|
|
329
|
+
env = process.env,
|
|
330
|
+
},
|
|
331
|
+
deps = {},
|
|
332
|
+
) {
|
|
314
333
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
315
334
|
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
335
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
336
|
+
let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
337
|
+
let authHeaders;
|
|
338
|
+
if (secret) {
|
|
339
|
+
authHeaders = {
|
|
340
|
+
authorization: `Bearer ${secret}`,
|
|
341
|
+
"x-tot-owner": tenant,
|
|
342
|
+
"x-tot-capability": "ship-on-behalf",
|
|
343
|
+
};
|
|
344
|
+
} else {
|
|
345
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
|
|
346
|
+
if (!viewer.ok) {
|
|
347
|
+
console.error(fail(viewer.message, viewer.hint));
|
|
348
|
+
return 2;
|
|
349
|
+
}
|
|
350
|
+
base = viewer.base;
|
|
351
|
+
authHeaders = viewer.authHeaders;
|
|
326
352
|
}
|
|
327
353
|
|
|
328
|
-
const authHeaders = {
|
|
329
|
-
authorization: `Bearer ${secret}`,
|
|
330
|
-
"x-tot-owner": tenant,
|
|
331
|
-
"x-tot-capability": "ship-on-behalf",
|
|
332
|
-
};
|
|
333
|
-
|
|
334
354
|
// Auto-surface the merge-doctor on a refused/failed ship. Best-effort:
|
|
335
355
|
// fetches the hosted doctor over the SAME operator transport and prints the COMPACT
|
|
336
356
|
// summary; never throws and never touches the exit code. A clean/unreachable doctor
|
|
@@ -387,6 +407,10 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
387
407
|
paywall: plan.paywall,
|
|
388
408
|
targets: { live: liveUrl },
|
|
389
409
|
});
|
|
410
|
+
if (acceptQualityReason) {
|
|
411
|
+
planLines.push(" quality findings: accept this exact revision and continue");
|
|
412
|
+
planLines.push(` acceptance reason: ${acceptQualityReason.trim() || "(missing)"}`);
|
|
413
|
+
}
|
|
390
414
|
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
391
415
|
yes,
|
|
392
416
|
question: `Ship ${tenant}'s current green aggregate live?`,
|
|
@@ -405,6 +429,36 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
405
429
|
return 0;
|
|
406
430
|
}
|
|
407
431
|
|
|
432
|
+
// This is the same revision-bound acceptance as the Publish dialog, not a raw validator bypass.
|
|
433
|
+
// The server refuses it unless the current durable problem is quality-only and the reason is
|
|
434
|
+
// substantive. Only after that accountable record exists do we re-dispatch the release.
|
|
435
|
+
if (acceptQualityReason !== null) {
|
|
436
|
+
let acceptRes;
|
|
437
|
+
try {
|
|
438
|
+
acceptRes = await fetchImpl(`${base}/api/changes/accept-issue`, {
|
|
439
|
+
method: "POST",
|
|
440
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
441
|
+
body: JSON.stringify({ operation: "publication", reason: acceptQualityReason }),
|
|
442
|
+
});
|
|
443
|
+
} catch (e) {
|
|
444
|
+
console.error(
|
|
445
|
+
fail(`couldn't record the quality acceptance at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
446
|
+
);
|
|
447
|
+
return 1;
|
|
448
|
+
}
|
|
449
|
+
const acceptData = await readJsonSafe(acceptRes);
|
|
450
|
+
if (!acceptRes.ok || acceptData?.ok !== true) {
|
|
451
|
+
console.error(
|
|
452
|
+
fail(
|
|
453
|
+
acceptData?.error || acceptData?.message || `quality acceptance was refused (HTTP ${acceptRes.status})`,
|
|
454
|
+
"review the current publish findings; only quality-only findings on this exact revision can be accepted",
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
console.log(" ✓ quality findings accepted for this revision; continuing the publish.");
|
|
460
|
+
}
|
|
461
|
+
|
|
408
462
|
// 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
|
|
409
463
|
// STALE plan (the aggregate moved / the artifact changed since GET) is
|
|
410
464
|
// refused rather than shipping something nobody reviewed.
|
|
@@ -528,6 +582,8 @@ export async function run(argv, ctx) {
|
|
|
528
582
|
storefrontUrl,
|
|
529
583
|
yes: args.yes,
|
|
530
584
|
noOpen: args.noOpen,
|
|
585
|
+
acceptQualityReason: args.acceptQualityReason,
|
|
586
|
+
env,
|
|
531
587
|
},
|
|
532
588
|
{ openUrl: (u) => openBrowser(u) },
|
|
533
589
|
);
|
|
@@ -16,14 +16,19 @@
|
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
|
|
19
|
+
import { validateVisualParityClosure } from "../visual-parity.mjs";
|
|
19
20
|
import { fail } from "../errors.mjs";
|
|
20
21
|
import { tenantDirSegments } from "../tenant-dirs.mjs";
|
|
21
22
|
|
|
23
|
+
const mergeFindings = (...groups) => [...new Map(groups.flat().map((item) => [JSON.stringify(item), item])).values()]
|
|
24
|
+
.sort((a, b) => `${a.file}\0${a.rule}\0${a.message}`.localeCompare(`${b.file}\0${b.rule}\0${b.message}`));
|
|
25
|
+
|
|
22
26
|
function parseArgs(argv) {
|
|
23
|
-
const a = { tenant: null, workspace: null, json: false, help: false };
|
|
27
|
+
const a = { tenant: null, workspace: null, json: false, help: false, profile: null, profileSpecified: false };
|
|
24
28
|
for (let i = 0; i < argv.length; i++) {
|
|
25
29
|
const t = argv[i];
|
|
26
30
|
if (t === "--workspace") a.workspace = argv[++i];
|
|
31
|
+
else if (t === "--profile") { a.profileSpecified = true; a.profile = argv[++i] ?? null; }
|
|
27
32
|
else if (t === "--json") a.json = true;
|
|
28
33
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
29
34
|
else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
|
|
@@ -36,6 +41,7 @@ const USAGE = `tot validate — lint your store before you submit
|
|
|
36
41
|
tot validate (inside a checkout) lint this store
|
|
37
42
|
tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
|
|
38
43
|
tot validate --workspace <dir> lint a specific checkout directory
|
|
44
|
+
tot validate --profile visual-parity close portable page/asset/link migration scope
|
|
39
45
|
tot validate --json machine-readable findings`;
|
|
40
46
|
|
|
41
47
|
/**
|
|
@@ -78,7 +84,11 @@ export function run(argv, ctx) {
|
|
|
78
84
|
return 2;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
if (args.profileSpecified && args.profile !== "visual-parity") {
|
|
88
|
+
console.error(fail(`unknown validation profile: ${args.profile}`, "use --profile visual-parity"));
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const baseline = validateTenant(dir, {
|
|
82
92
|
tenantId: target.tenantId ?? undefined,
|
|
83
93
|
scope: target.scope ?? undefined,
|
|
84
94
|
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
|
@@ -86,6 +96,14 @@ export function run(argv, ctx) {
|
|
|
86
96
|
// arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
|
|
87
97
|
mode: args.workspace ? "workspace" : "monorepo",
|
|
88
98
|
});
|
|
99
|
+
const result = args.profile === "visual-parity"
|
|
100
|
+
? (() => {
|
|
101
|
+
const parity = validateVisualParityClosure(dir, { tenantId: target.tenantId ?? undefined });
|
|
102
|
+
const findings = mergeFindings(baseline.findings, parity.findings);
|
|
103
|
+
return { ok: !findings.some((item) => item.level === ERROR), findings };
|
|
104
|
+
})()
|
|
105
|
+
: baseline;
|
|
106
|
+
const { ok, findings } = result;
|
|
89
107
|
|
|
90
108
|
if (args.json) {
|
|
91
109
|
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
@@ -105,7 +123,7 @@ export function run(argv, ctx) {
|
|
|
105
123
|
// the findings below say why; the header should say so too, not disguise
|
|
106
124
|
// a directory as a domain.
|
|
107
125
|
const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
|
|
108
|
-
console.log(`\ntot validate — ${label}\n`);
|
|
126
|
+
console.log(`\ntot validate${args.profile ? ` --profile ${args.profile}` : ""} — ${label}\n`);
|
|
109
127
|
for (const f of findings) {
|
|
110
128
|
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
111
129
|
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
package/src/validate.mjs
CHANGED
|
@@ -142,8 +142,8 @@ function validateRawHtmlBody(html) {
|
|
|
142
142
|
// CLI bundle can't reach the repo's public-runtime package at runtime (same
|
|
143
143
|
// constraint as the KNOWN_EMBEDS mirror above), so this is a copy — parity
|
|
144
144
|
// test: packages/public-runtime/tests/chrome-config-shape-parity.test.ts.
|
|
145
|
-
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
|
|
146
|
-
const CHROME_FOOTER_VARIANTS = new Set(["default"]);
|
|
145
|
+
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal", "compact-centered"]);
|
|
146
|
+
const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible"]);
|
|
147
147
|
/**
|
|
148
148
|
* The declarative-interaction-tracking rule, mirrored: every actionable item —
|
|
149
149
|
* an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only closure checks for the optional visual-parity migration module.
|
|
3
|
+
* The tenant checkout remains portable: this reads only content/, public/, and
|
|
4
|
+
* .tot/visual-parity.json, never a preview host or a delivery provider.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { extname, join, relative } from "node:path";
|
|
9
|
+
|
|
10
|
+
const ERROR = "error";
|
|
11
|
+
// Public routes implemented by the renderer. Authentication and cart URLs are
|
|
12
|
+
// deliberately absent: they are flow-specific, not portable parity targets.
|
|
13
|
+
const PLATFORM_ROUTES = ["/collections", "/collections/", "/products/", "/search", "/saved", "/account", "/account/", "/blog", "/blog/"];
|
|
14
|
+
const ASSET_EXTENSIONS = new Set([".avif", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
|
|
15
|
+
|
|
16
|
+
const finding = (rule, file, message, fix) => ({ level: ERROR, rule, file, message, fix });
|
|
17
|
+
const walk = (dir) => !existsSync(dir) ? [] : readdirSync(dir).flatMap((name) => {
|
|
18
|
+
const path = join(dir, name);
|
|
19
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
20
|
+
});
|
|
21
|
+
const pointer = (parts) => parts.length ? `/${parts.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}` : "/";
|
|
22
|
+
const stripQuery = (value) => value.split(/[?#]/, 1)[0];
|
|
23
|
+
const normalizeRoute = (value) => {
|
|
24
|
+
const path = stripQuery(value).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
|
|
25
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
26
|
+
};
|
|
27
|
+
const hasDotSegment = (value) => stripQuery(value).split("/").some((part) => part === "." || part === ".." || /^%2e(?:%2e)?$/i.test(part));
|
|
28
|
+
const safeLocator = (value) => {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(value.startsWith("//") ? `https:${value}` : value);
|
|
31
|
+
return url.origin;
|
|
32
|
+
} catch {
|
|
33
|
+
if (value.startsWith("/")) return normalizeRoute(value);
|
|
34
|
+
return "[redacted authored value]";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function parseConfig(tenantDir) {
|
|
39
|
+
const path = join(tenantDir, ".tot", "visual-parity.json");
|
|
40
|
+
if (!existsSync(path)) return { value: {}, findings: [] };
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (!value || Array.isArray(value) || typeof value !== "object") throw new Error("must be a JSON object");
|
|
44
|
+
const findings = [];
|
|
45
|
+
if (value.externalNavigationAllowlist !== undefined && !Array.isArray(value.externalNavigationAllowlist)) {
|
|
46
|
+
findings.push(finding("parity-config-shape", ".tot/visual-parity.json", "externalNavigationAllowlist must be an array of HTTPS origins or URLs", "replace it with an explicit reviewed array, for example [\"https://partner.example\"]"));
|
|
47
|
+
}
|
|
48
|
+
for (const [index, entry] of (Array.isArray(value.externalNavigationAllowlist) ? value.externalNavigationAllowlist : []).entries()) {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(entry);
|
|
51
|
+
if (typeof entry !== "string" || url.protocol !== "https:" || url.username || url.password || /[?#]/.test(entry)) throw new Error("invalid");
|
|
52
|
+
} catch {
|
|
53
|
+
findings.push(finding("parity-allowlist-entry", `.tot/visual-parity.json/externalNavigationAllowlist/${index}`, "allowlist entries must be HTTPS origins or exact path prefixes without credentials, query, or fragment", "use https://partner.example for an origin or https://partner.example/returns for that path boundary"));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { value, findings };
|
|
57
|
+
} catch {
|
|
58
|
+
return { value: {}, findings: [finding("parity-config-parse", ".tot/visual-parity.json", "invalid JSON", "fix .tot/visual-parity.json before closing visual parity")] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tenantFromConfig(tenantDir) {
|
|
63
|
+
try {
|
|
64
|
+
const value = JSON.parse(readFileSync(join(tenantDir, ".tot", "config.json"), "utf8"));
|
|
65
|
+
return typeof value?.tenant === "string" ? value.tenant : null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function allowedNavigation(value, allowlist) {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(value);
|
|
72
|
+
return allowlist.some((entry) => {
|
|
73
|
+
try {
|
|
74
|
+
const allowed = new URL(entry);
|
|
75
|
+
if (allowed.protocol !== "https:" || allowed.username || allowed.password) return false;
|
|
76
|
+
if (allowed.pathname === "/") return url.origin === allowed.origin;
|
|
77
|
+
return url.origin === allowed.origin && (url.pathname === allowed.pathname || url.pathname.startsWith(`${allowed.pathname.replace(/\/$/, "")}/`));
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
});
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assetType(bytes) {
|
|
84
|
+
const ascii = bytes.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
|
|
85
|
+
if (pngValid(bytes)) return ".png";
|
|
86
|
+
if (bytes.subarray(0, 3).toString("ascii") === "GIF") return ".gif";
|
|
87
|
+
if (jpegValid(bytes)) return ".jpg";
|
|
88
|
+
if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return ".webp";
|
|
89
|
+
if (bytes.subarray(4, 8).toString("ascii") === "ftyp" && bytes.subarray(8, 12).toString("ascii").toLowerCase().includes("avif")) return ".avif";
|
|
90
|
+
if (bytes.subarray(0, 4).toString("ascii") === "wOFF") return ".woff";
|
|
91
|
+
if (bytes.subarray(0, 4).toString("ascii") === "wOF2") return ".woff2";
|
|
92
|
+
if (icoValid(bytes)) return ".ico";
|
|
93
|
+
if (ascii.startsWith("<svg") || ascii.startsWith("<?xml") && ascii.includes("<svg")) return ".svg";
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
function pngValid(bytes) {
|
|
97
|
+
if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return false;
|
|
98
|
+
let pos = 8, sawIhdr = false;
|
|
99
|
+
let sawIdat = false;
|
|
100
|
+
while (pos + 12 <= bytes.length) {
|
|
101
|
+
const length = bytes.readUInt32BE(pos); const type = bytes.subarray(pos + 4, pos + 8).toString("ascii");
|
|
102
|
+
if (length > bytes.length - pos - 12) return false;
|
|
103
|
+
if (type === "IHDR") { if (sawIhdr || length !== 13 || bytes.readUInt32BE(pos + 8) === 0 || bytes.readUInt32BE(pos + 12) === 0) return false; sawIhdr = true; }
|
|
104
|
+
if (type === "IDAT" && length > 0) sawIdat = true;
|
|
105
|
+
pos += 12 + length; if (type === "IEND") return sawIhdr && sawIdat && length === 0 && pos === bytes.length;
|
|
106
|
+
} return false;
|
|
107
|
+
}
|
|
108
|
+
function jpegValid(bytes) {
|
|
109
|
+
if (bytes.length < 10 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
|
|
110
|
+
let pos = 2, sof = false;
|
|
111
|
+
while (pos < bytes.length) {
|
|
112
|
+
if (bytes[pos++] !== 0xff) return false; while (bytes[pos] === 0xff) pos++;
|
|
113
|
+
const marker = bytes[pos++]; if (marker === 0xd9) return sof && pos === bytes.length;
|
|
114
|
+
if (marker === 0xda) { if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); const scan = pos + length; return sof && scan < bytes.length - 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; }
|
|
115
|
+
if (marker === 0x01 || marker >= 0xd0 && marker <= 0xd7) continue;
|
|
116
|
+
if (pos + 2 > bytes.length) return false; const length = bytes.readUInt16BE(pos); if (length < 2 || pos + length > bytes.length) return false;
|
|
117
|
+
if (marker >= 0xc0 && marker <= 0xc3 && length >= 8) { if (bytes.readUInt16BE(pos + 3) === 0 || bytes.readUInt16BE(pos + 5) === 0) return false; sof = true; }
|
|
118
|
+
pos += length;
|
|
119
|
+
} return false;
|
|
120
|
+
}
|
|
121
|
+
function icoValid(bytes) {
|
|
122
|
+
if (bytes.length < 22 || bytes.readUInt16LE(0) !== 0 || bytes.readUInt16LE(2) !== 1) return false;
|
|
123
|
+
const count = bytes.readUInt16LE(4); if (!count || bytes.length < 6 + count * 16) return false;
|
|
124
|
+
for (let i = 0; i < count; i++) { const at = 6 + i * 16, size = bytes.readUInt32LE(at + 8), offset = bytes.readUInt32LE(at + 12); if (size && offset >= 6 + count * 16 && offset + size <= bytes.length) { const payload = bytes.subarray(offset, offset + size); if (pngValid(payload) || payload.length >= 40 && payload.readInt32LE(0) >= 40 && payload.readInt32LE(4) > 0 && payload.readInt32LE(8) > 0) return true; } }
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkAsset(value, at, file, context) {
|
|
129
|
+
const { findings, tenantId, publicDir } = context;
|
|
130
|
+
if (!value || value.startsWith("#")) return;
|
|
131
|
+
if (/^data:/i.test(value)) {
|
|
132
|
+
findings.push(finding("parity-data-asset", `${file}${at}`, "embedded data assets are not portable parity inputs", "copy the asset into public/ and use a tenant-local path"));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) {
|
|
136
|
+
findings.push(finding("parity-external-asset", `${file}${at}`, `asset source ${safeLocator(value)} is off-host`, "copy the asset into public/ and reference it with /tenants/<tenant>/..."));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const path = stripQuery(value);
|
|
140
|
+
let rel;
|
|
141
|
+
if (path.startsWith("/tenants/")) {
|
|
142
|
+
const prefix = `/tenants/${tenantId}/`;
|
|
143
|
+
if (!tenantId || !path.startsWith(prefix)) {
|
|
144
|
+
findings.push(finding("parity-cross-tenant-asset", `${file}${at}`, "asset does not belong to this tenant", "copy the asset into this tenant's public/ directory"));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
rel = path.slice(prefix.length);
|
|
148
|
+
} else if (path.startsWith("/")) {
|
|
149
|
+
findings.push(finding("parity-unowned-asset", `${file}${at}`, "asset is not tenant-local", "use a tenant-local asset committed under public/"));
|
|
150
|
+
return;
|
|
151
|
+
} else rel = path;
|
|
152
|
+
if (!rel || hasDotSegment(rel)) {
|
|
153
|
+
findings.push(finding("parity-asset-path", `${file}${at}`, "asset path escapes or does not name public/", "use a direct tenant-local path under public/"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const asset = join(publicDir, rel);
|
|
157
|
+
if (!existsSync(asset)) {
|
|
158
|
+
findings.push(finding("parity-asset-missing", `${file}${at}`, "tenant-local asset is absent from public/", "add the referenced asset under public/ or correct the reference"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const extension = extname(rel).toLowerCase();
|
|
162
|
+
if (!ASSET_EXTENSIONS.has(extension)) {
|
|
163
|
+
findings.push(finding("parity-asset-extension", `${file}${at}`, "asset has an unsupported extension", "re-export favicon/assets as PNG or SVG; font containers require platform-managed validation"));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (statSync(asset).isDirectory()) {
|
|
167
|
+
findings.push(finding("parity-asset-directory", `${file}${at}`, "asset names a directory", "reference a file under public/"));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (ASSET_EXTENSIONS.has(extension)) {
|
|
171
|
+
const actual = assetType(readFileSync(asset));
|
|
172
|
+
const compatible = actual === extension || actual === ".jpg" && extension === ".jpeg";
|
|
173
|
+
if (!actual) findings.push(finding("parity-asset-undecodable", `${file}${at}`, `asset does not pass bounded ${extension} structural integrity checks`, "replace it with a structurally valid file of the declared type"));
|
|
174
|
+
else if (!compatible) findings.push(finding("parity-asset-type", `${file}${at}`, `asset bytes do not match the ${extension} extension`, "export the asset in the declared format"));
|
|
175
|
+
else if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg"].includes(extension)) context.decodeAssets.push({ asset, file: `${file}${at}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function decodeImages(entries) {
|
|
180
|
+
if (!entries.length) return new Set();
|
|
181
|
+
const source = "const{createRequire}=require('node:module');const sharp=createRequire(process.argv[1])('sharp');(async()=>{let a=JSON.parse(process.argv[2]);let o=[];for(const p of a){try{let r=await sharp(p,{failOn:'error'}).raw().toBuffer({resolveWithObject:true});o.push(r.info.width>0&&r.info.height>0)}catch{o.push(false)}}process.stdout.write(JSON.stringify(o))})().catch(()=>process.exit(1));";
|
|
182
|
+
const result = spawnSync(process.execPath, ["-e", source, import.meta.url, JSON.stringify(entries.map((entry) => entry.asset))], { encoding: "utf8", timeout: 15000 });
|
|
183
|
+
try { if (result.status !== 0) throw new Error(); const output = JSON.parse(result.stdout); return new Set(entries.flatMap((_, index) => output[index] ? [] : [index])); } catch { return new Set(entries.map((_, index) => index)); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function inspectHtml(html, file, at, context) {
|
|
187
|
+
const attr = /\b(href|src)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))/gi;
|
|
188
|
+
let index = 0;
|
|
189
|
+
for (const match of html.matchAll(attr)) {
|
|
190
|
+
const value = (match[3] ?? match[4] ?? "").trim();
|
|
191
|
+
inspectReference(match[1].toLowerCase(), value, `${at}/html-attribute-${index++}`, file, context);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function inspectReference(kind, value, at, file, context) {
|
|
195
|
+
if (kind === "src") return checkAsset(value, at, file, context);
|
|
196
|
+
if (!value || value.startsWith("#") || /^(mailto:|tel:)/i.test(value)) return;
|
|
197
|
+
if (/^(javascript|vbscript|data):/i.test(value)) {
|
|
198
|
+
context.findings.push(finding("parity-executable-link", `${file}${at}`, "link uses an executable scheme", "replace it with a local route or a reviewed HTTPS destination"));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (/^https?:\/\//i.test(value) || value.startsWith("//")) {
|
|
202
|
+
if (!allowedNavigation(value.startsWith("//") ? `https:${value}` : value, context.allowlist)) context.findings.push(finding("parity-external-navigation", `${file}${at}`, `external navigation ${safeLocator(value)} is not on the reviewed allowlist`, "add its HTTPS origin or URL to .tot/visual-parity.json only after human review"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!value.startsWith("/")) {
|
|
206
|
+
context.findings.push(finding("parity-relative-link", `${file}${at}`, "relative link has no stable paired route", "use a root-absolute local route"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (hasDotSegment(value)) {
|
|
210
|
+
context.findings.push(finding("parity-route-path", `${file}${at}`, "local route contains a dot segment", "use a canonical root-absolute route"));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const route = normalizeRoute(value);
|
|
214
|
+
if (!context.routes.has(route) && !PLATFORM_ROUTES.some((platform) => platform.endsWith("/") ? route.startsWith(platform) : route === platform)) context.findings.push(finding("parity-route-missing", `${file}${at}`, "local route is not declared by a page slug or platform", "add the matching content/pages JSON record or correct the link"));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function inspectValue(value, file, parts, context) {
|
|
218
|
+
if (Array.isArray(value)) return value.forEach((item, index) => inspectValue(item, file, [...parts, index], context));
|
|
219
|
+
if (!value || typeof value !== "object") return;
|
|
220
|
+
for (const [key, child] of Object.entries(value)) {
|
|
221
|
+
const next = [...parts, key];
|
|
222
|
+
if (key === "body_html" && typeof child === "string") inspectHtml(child, file, pointer(next), context);
|
|
223
|
+
if ((key === "href" || key === "ctaHref") && typeof child === "string") inspectReference("href", child, pointer(next), file, context);
|
|
224
|
+
if ((key === "src" || key === "image" || key === "icon" || key === "logo" || key === "logoDark" || key === "favicon") && typeof child === "string") checkAsset(child, pointer(next), file, context);
|
|
225
|
+
if (typeof child === "string" && /(?:css|style|stylesheet)$/i.test(key)) {
|
|
226
|
+
for (const match of child.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gi)) checkAsset(match[2].trim(), `${pointer(next)}/css`, file, context);
|
|
227
|
+
}
|
|
228
|
+
inspectValue(child, file, next, context);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pageShape(value, file) {
|
|
233
|
+
const findings = [];
|
|
234
|
+
const required = [["slug", "string"], ["title", "string"]];
|
|
235
|
+
for (const [key, type] of required) {
|
|
236
|
+
const valid = type === "array" ? Array.isArray(value[key]) : typeof value[key] === type;
|
|
237
|
+
if (!valid || (type === "string" && value[key].trim() === "")) findings.push(finding("parity-page-field", `${file}/${key}`, `${key} must be a non-empty ${type}`, `set ${key} to the page value expected by the renderer`));
|
|
238
|
+
}
|
|
239
|
+
if (value.isPolicy !== undefined && typeof value.isPolicy !== "boolean") findings.push(finding("parity-page-field", `${file}/isPolicy`, "isPolicy must be a boolean when present", "set isPolicy to true or false"));
|
|
240
|
+
if (value.sections !== undefined && !Array.isArray(value.sections)) findings.push(finding("parity-page-field", `${file}/sections`, "sections must be an array when present", "set sections to an array of renderer-supported sections"));
|
|
241
|
+
if (Array.isArray(value.sections)) value.sections.forEach((section, index) => {
|
|
242
|
+
const at = `${file}/sections/${index}`;
|
|
243
|
+
if (!section || typeof section !== "object" || Array.isArray(section)) return findings.push(finding("parity-section-shape", at, "section must be an object", "use a renderer-supported prose section"));
|
|
244
|
+
const string = (v) => typeof v === "string" && v.trim() !== "";
|
|
245
|
+
const rich = (v) => typeof v === "string" ? v.trim() !== "" : Array.isArray(v) && v.length > 0 && v.every((run) => run && typeof run === "object" && !Array.isArray(run) && ((run.kind === "text" && string(run.text) && (run.strong === undefined || typeof run.strong === "boolean") && (run.em === undefined || typeof run.em === "boolean")) || (run.kind === "link" && string(run.text) && string(run.href) && (run.external === undefined || typeof run.external === "boolean"))));
|
|
246
|
+
switch (section.kind) {
|
|
247
|
+
case "heading": if (!string(section.text) || section.level !== undefined && section.level !== 2 && section.level !== 3 || section.id !== undefined && !string(section.id)) findings.push(finding("parity-section-shape", at, "heading requires string text, optional id, and level 2 or 3", "supply a valid heading")); break;
|
|
248
|
+
case "paragraph": if (!rich(section.text)) findings.push(finding("parity-section-shape", at, "paragraph requires RichText", "supply a valid string or inline-run array")); break;
|
|
249
|
+
case "list": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !rich(item)) || section.ordered !== undefined && typeof section.ordered !== "boolean") findings.push(finding("parity-section-shape", at, "list requires RichText items and optional boolean ordered", "supply valid list items")); break;
|
|
250
|
+
case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
|
|
251
|
+
case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
|
|
252
|
+
case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
|
|
253
|
+
case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
|
|
254
|
+
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
|
|
258
|
+
const sections = Array.isArray(value.sections) && value.sections.length > 0;
|
|
259
|
+
if (!bodyHtml && !sections) findings.push(finding("parity-page-body", file, "page needs non-empty body_html or non-empty sections", "add rendered body_html or one or more sections"));
|
|
260
|
+
return findings;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readStructured(path, file, findings) {
|
|
264
|
+
try { return JSON.parse(readFileSync(path, "utf8")); }
|
|
265
|
+
catch { findings.push(finding("parity-content-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); return null; }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @typedef {{level:string, rule:string, file:string, message:string, fix:string}} ParityFinding */
|
|
269
|
+
/** @typedef {{asset:string, file:string}} DecodeAsset */
|
|
270
|
+
/** @typedef {{tenantId?: string}} VisualParityOptions */
|
|
271
|
+
/** Validate the portable content closure required before human visual-parity review.
|
|
272
|
+
* @param {string} tenantDir
|
|
273
|
+
* @param {VisualParityOptions} [options]
|
|
274
|
+
*/
|
|
275
|
+
export function validateVisualParityClosure(tenantDir, options = {}) {
|
|
276
|
+
const { tenantId } = options;
|
|
277
|
+
/** @type {ParityFinding[]} */
|
|
278
|
+
const findings = [];
|
|
279
|
+
const { value: config, findings: configFindings } = parseConfig(tenantDir);
|
|
280
|
+
findings.push(...configFindings);
|
|
281
|
+
const pagesDir = join(tenantDir, "content", "pages");
|
|
282
|
+
const files = walk(pagesDir).filter((file) => file.endsWith(".json")).sort();
|
|
283
|
+
if (files.length === 0) findings.push(finding("parity-pages-missing", "content/pages/", "no page JSON files define the visual-parity route scope", "add the paired authored page records under content/pages/ before closing parity"));
|
|
284
|
+
const pages = [];
|
|
285
|
+
for (const path of files) {
|
|
286
|
+
const file = relative(tenantDir, path);
|
|
287
|
+
try {
|
|
288
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
289
|
+
if (!value || Array.isArray(value) || typeof value !== "object") {
|
|
290
|
+
findings.push(finding("parity-page-shape", file, "page JSON must be an object", "write a page object with a non-empty slug")); continue;
|
|
291
|
+
}
|
|
292
|
+
findings.push(...pageShape(value, file));
|
|
293
|
+
if (typeof value.slug !== "string" || !value.slug.trim()) continue;
|
|
294
|
+
pages.push({ file, value });
|
|
295
|
+
} catch { findings.push(finding("parity-page-parse", file, "invalid JSON", "repair the JSON before visual-parity closure")); }
|
|
296
|
+
}
|
|
297
|
+
const routes = new Set(["/"]);
|
|
298
|
+
for (const page of pages) routes.add(normalizeRoute(page.value.slug));
|
|
299
|
+
/** @type {{findings:ParityFinding[], tenantId:string|null, publicDir:string, routes:Set<string>, allowlist:string[], decodeAssets:DecodeAsset[]}} */
|
|
300
|
+
const context = { findings, tenantId: tenantId ?? tenantFromConfig(tenantDir), publicDir: join(tenantDir, "public"), routes, allowlist: Array.isArray(config.externalNavigationAllowlist) ? config.externalNavigationAllowlist : [], decodeAssets: [] };
|
|
301
|
+
if (!context.tenantId) findings.push(finding("parity-tenant-id", ".tot/config.json", "tenant id is required to verify /tenants/<tenant>/ assets", "set tenant in .tot/config.json or pass a resolved tenant id"));
|
|
302
|
+
for (const page of pages) inspectValue(page.value, page.file, [], context);
|
|
303
|
+
for (const name of ["home.json", "chrome.json"]) {
|
|
304
|
+
const path = join(tenantDir, "content", name);
|
|
305
|
+
if (existsSync(path)) {
|
|
306
|
+
const value = readStructured(path, `content/${name}`, findings);
|
|
307
|
+
if (value != null) inspectValue(value, `content/${name}`, [], context);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const themePath = join(tenantDir, "theme.json");
|
|
311
|
+
if (existsSync(themePath)) {
|
|
312
|
+
const value = readStructured(themePath, "theme.json", findings);
|
|
313
|
+
if (value != null) inspectValue(value, "theme.json", [], context);
|
|
314
|
+
}
|
|
315
|
+
for (const index of decodeImages(context.decodeAssets)) {
|
|
316
|
+
const entry = context.decodeAssets[index];
|
|
317
|
+
if (entry) findings.push(finding("parity-asset-decode", entry.file, "image decoder could not produce nonzero pixels", "replace the image with a decodable file"));
|
|
318
|
+
}
|
|
319
|
+
findings.sort((a, b) => `${a.file}\0${a.rule}`.localeCompare(`${b.file}\0${b.rule}`));
|
|
320
|
+
return { ok: findings.length === 0, profile: "visual-parity", findings };
|
|
321
|
+
}
|