@tokenoftrust/cli 2.0.2 → 2.0.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
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",
@@ -52,7 +52,7 @@ import { checkoutTenant } from "./clone.mjs";
52
52
  // born-rebased submit rebuilds a candidate onto the current base with the SAME
53
53
  // engine `tot accept --refresh` uses, so the two read its result identically).
54
54
  import { normalizeRefreshResult } from "./accept.mjs";
55
- import { validateTenant, ERROR } from "../validate.mjs";
55
+ import { validateTenant, ERROR, printLoudAdvisories } from "../validate.mjs";
56
56
  import { openBrowser } from "../open.mjs";
57
57
  import { startProgress } from "../progress.mjs";
58
58
  import { fail } from "../errors.mjs";
@@ -1356,33 +1356,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1356
1356
 
1357
1357
  // 1. validate locally — refuse on errors.
1358
1358
  if (!args.skipValidate) {
1359
- /** Warnings that would otherwise be swallowed, but describe a defect that ships
1360
- * looking healthy: [rule, headline, what it costs if ignored]. */
1361
- const LOUD_ADVISORY_RULES = [
1362
- [
1363
- "git-conflict-markers",
1364
- "git conflict markers in submitted content — an unfinished merge/rebase?",
1365
- "The preview will still build, but it will serve the broken markers. Resolve before shipping.",
1366
- ],
1367
- [
1368
- "duplicate-skip-link",
1369
- "duplicate skip link — your chrome already supplies one",
1370
- "The page renders fine and every automated check passes; a screen-reader user hears it twice.",
1371
- ],
1372
- ];
1373
1359
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
1374
- // Advisory but LOUD. Warnings are otherwise swallowed on the ok path, which is
1375
- // wrong for defects that SHIP SILENTLY: the preview builds, the console is clean,
1376
- // reconcile and compliance pass, and the flaw only surfaces to whoever reads the
1377
- // rendered page. Those get surfaced here never blocking, since none of them
1378
- // makes the candidate unservable.
1379
- for (const [rule, headline, consequence] of LOUD_ADVISORY_RULES) {
1380
- const hits = findings.filter((f) => f.rule === rule);
1381
- if (!hits.length) continue;
1382
- console.error(`\n⚠ ${headline} (${hits.length} file(s))`);
1383
- for (const f of hits) console.error(` ⚠ ${f.file} — ${f.message}`);
1384
- console.error(` ${consequence}\n`);
1385
- }
1360
+ // Advisory but LOUD (LOUD_ADVISORY_RULES, ../validate.mjs shared with `tot
1361
+ // validate` so the callout reads identically from either command). Warnings
1362
+ // are otherwise swallowed on the ok path, which is wrong for defects that SHIP
1363
+ // SILENTLY: the preview builds, the console is clean, reconcile and compliance
1364
+ // pass, and the flaw only surfaces to whoever reads the rendered page. Those
1365
+ // get surfaced here — never blocking, since none of them makes the candidate
1366
+ // unservable.
1367
+ printLoudAdvisories(findings);
1386
1368
  if (!ok) {
1387
1369
  const errs = findings.filter((f) => f.level === ERROR);
1388
1370
  console.error(
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { existsSync } from "node:fs";
17
17
  import { join, resolve } from "node:path";
18
- import { validateTenant, ERROR, WARN } from "../validate.mjs";
18
+ import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
19
19
  import { fail } from "../errors.mjs";
20
20
  import { tenantDirSegments } from "../tenant-dirs.mjs";
21
21
 
@@ -94,6 +94,12 @@ export function run(argv, ctx) {
94
94
 
95
95
  const errors = findings.filter((f) => f.level === ERROR);
96
96
  const warns = findings.filter((f) => f.level === WARN);
97
+ // Named, grouped callouts for the advisories that ship a page looking healthy
98
+ // while quietly losing something — printed FIRST so the one step worth acting
99
+ // on (assign stable tracking ids) reads as a named step, not a warning buried
100
+ // among the full findings dump below. Same table `tot submit`/`tot preview`
101
+ // print, so the nudge reads identically wherever it's seen.
102
+ printLoudAdvisories(findings);
97
103
  // Never present the checkout PATH as if it were the tenant name — when the
98
104
  // tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
99
105
  // the findings below say why; the header should say so too, not disguise
package/src/validate.mjs CHANGED
@@ -144,6 +144,25 @@ function validateRawHtmlBody(html) {
144
144
  // test: packages/public-runtime/tests/chrome-config-shape-parity.test.ts.
145
145
  const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
146
146
  const CHROME_FOOTER_VARIANTS = new Set(["default"]);
147
+ /**
148
+ * The declarative-interaction-tracking rule, mirrored: every actionable item —
149
+ * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
150
+ * `action` (its stable intent) or `tracking: "none"` (an explicit opt-out).
151
+ * Declaring neither or both is a violation, and nothing is derived from `id`.
152
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
153
+ */
154
+ function chromeTrackingDeclared(value) {
155
+ if (Array.isArray(value)) return value.every(chromeTrackingDeclared);
156
+ if (value == null || typeof value !== "object") return true;
157
+ if (typeof value.id === "string" && typeof value.href === "string") {
158
+ const hasAction = typeof value.action === "string" && value.action.trim() !== "";
159
+ const optedOut = value.tracking === "none";
160
+ if (hasAction === optedOut) return false;
161
+ if (value.tracking !== undefined && !optedOut) return false;
162
+ }
163
+ return Object.values(value).every(chromeTrackingDeclared);
164
+ }
165
+
147
166
  export function looksLikeChromeConfig(value) {
148
167
  if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
149
168
  const header = value.header;
@@ -156,6 +175,7 @@ export function looksLikeChromeConfig(value) {
156
175
  if (footer == null || typeof footer !== "object" || Array.isArray(footer)) return false;
157
176
  if (!CHROME_FOOTER_VARIANTS.has(footer.variant)) return false;
158
177
  if (!Array.isArray(footer.columns)) return false;
178
+ if (!chromeTrackingDeclared(value)) return false;
159
179
  return true;
160
180
  }
161
181
 
@@ -428,6 +448,278 @@ function validateHomeDoc(doc, file) {
428
448
  return out;
429
449
  }
430
450
 
451
+ // --- CTA / actionable-identity advisory rules (unit dt-validator-cta-rules) -
452
+ // Three ADVISORY warnings (never block) that a schema pass alone doesn't
453
+ // reach. `validateChromeConfig` (@tot/public-runtime chrome.ts) already
454
+ // HARD-FAILS the governed chrome.json path on the either/or action/
455
+ // tracking:"none" rule (interaction-tracking.ts validateTracking) — this
456
+ // validator does not re-run that schema (deep per-item errors are the
457
+ // write-path's job, per the looksLikeChromeConfig comment above). These three
458
+ // catch what the schema pass doesn't:
459
+ // - `duplicate-cta` — a copy-paste mistake that still PASSES schema (two
460
+ // actionable items sharing one governed identity).
461
+ // - `cta-missing-id` — tenant-authored raw HTML fragments carry NO
462
+ // `data-tot-*` at all pre-derivation (see docs/architecture/interaction-
463
+ // tracking-attribute-contract.md "Deriving data-tot-el") — there is no
464
+ // schema here for validateChromeConfig to fail.
465
+ // - `cta-id-drift` — `data-tot-el` is a PERMANENT identity per
466
+ // dt-contract-v1's decision ledger ("a change here is DRIFT and is a
467
+ // defect"); this one needs a PRIOR version to compare against, which a
468
+ // single-snapshot validateTenant() run doesn't have on its own — see
469
+ // `opts.previousChromeJson`, below.
470
+ // Kept in step with scripts/tenant/validate.mjs's identical copy of this block.
471
+
472
+ // --- "assign stable tracking ids" — the one named, actionable step (unit
473
+ // dt-assign-ids-step). The three rules above are DETECTION; this is the single
474
+ // place every touchpoint (`tot validate`, `tot submit`/`tot preview`, and the
475
+ // `tot dev` save-loop nudge — scripts/dev/checkout-watch.mjs) points an author
476
+ // at the SAME fix in the SAME words, so it reads as one coherent nudge no
477
+ // matter which command surfaced it, never a fresh, differently-worded warning
478
+ // each time. The mechanism already exists (derivation: dt-id-derivation;
479
+ // writeback: dt-id-writeback, apps/storefront/src/pages/api/admin/tracking-
480
+ // writeback.ts) — this names the step and points at it, it invents nothing.
481
+ export const TRACKING_ID_STEP_NAME = "assign stable tracking ids";
482
+ export const CTA_MISSING_ID_FIX =
483
+ "assign stable tracking ids: GET/POST /api/admin/tracking-writeback proposes a data-tot-el for every element that's missing one, as a reviewable candidate PR (GET first for a dry-run preview) — or run `tot validate` to see the full list before you submit.";
484
+
485
+ /**
486
+ * The advisory rules LOUD enough to call out by NAME at `tot validate` / `tot
487
+ * submit` / `tot preview` — none of them block, but each ships a page that
488
+ * looks perfectly healthy (clean build, every other check green) while
489
+ * quietly losing something. `[rule, headline, what it costs if ignored]`.
490
+ * ONE place so every surface prints identical wording (see the block comment
491
+ * above); `printLoudAdvisories` is the shared renderer.
492
+ */
493
+ export const LOUD_ADVISORY_RULES = [
494
+ [
495
+ "git-conflict-markers",
496
+ "git conflict markers in submitted content — an unfinished merge/rebase?",
497
+ "The preview will still build, but it will serve the broken markers. Resolve before shipping.",
498
+ ],
499
+ [
500
+ "duplicate-skip-link",
501
+ "duplicate skip link — your chrome already supplies one",
502
+ "The page renders fine and every automated check passes; a screen-reader user hears it twice.",
503
+ ],
504
+ [
505
+ "duplicate-cta",
506
+ "duplicate CTA identity — two actionable items share one data-tot-el",
507
+ "Both elements still work, but the click listener and every downstream analytics query treat them as ONE interaction — you lose the ability to tell them apart.",
508
+ ],
509
+ [
510
+ "cta-missing-id",
511
+ `actionable element with no governed identity (data-tot-el) — needs to ${TRACKING_ID_STEP_NAME}`,
512
+ `It renders and works fine, but it's invisible to interaction tracking — nothing about it is ever recorded. ${CTA_MISSING_ID_FIX}`,
513
+ ],
514
+ [
515
+ "cta-id-drift",
516
+ "a previously-declared data-tot-el changed value",
517
+ "The page still works, but everything already recorded under the old id is now orphaned — this identity is supposed to be permanent.",
518
+ ],
519
+ ];
520
+
521
+ /**
522
+ * Print the LOUD-but-advisory findings as named, grouped callouts — one block
523
+ * per rule with a headline, every hit, and the consequence of ignoring it —
524
+ * so the step worth acting on (assign stable tracking ids) reads as a NAMED
525
+ * step, not a warning buried among dozens. Shared by `tot validate` and `tot
526
+ * submit`/`tot preview` so a developer sees the identical callout regardless
527
+ * of which command surfaced it. `log` defaults to `console.error` (this CLI's
528
+ * existing convention for advisory noise); injectable for tests.
529
+ * @param {Finding[]} findings
530
+ * @param {{ log?: (s: string) => void }} [opts]
531
+ */
532
+ export function printLoudAdvisories(findings, { log = (s) => console.error(s) } = {}) {
533
+ for (const [rule, headline, consequence] of LOUD_ADVISORY_RULES) {
534
+ const hits = findings.filter((f) => f.rule === rule);
535
+ if (!hits.length) continue;
536
+ log(`\n⚠ ${headline} (${hits.length} finding(s))`);
537
+ for (const f of hits) log(` ⚠ ${f.file} — ${f.message}`);
538
+ log(` ${consequence}\n`);
539
+ }
540
+ }
541
+
542
+ /** Every declared actionable `id` in a ChromeConfig, with a path for
543
+ * messages — the SAME field rawChrome.ts / SiteHeader.astro stamp verbatim as
544
+ * `data-tot-el` (ctaIdentityAttr for header.ctas; the direct `id` field on
545
+ * every other actionable shape — see @tot/public-runtime chrome.ts's
546
+ * ChromeLink/ChromeNavItem/ChromeCta/ChromeActionLink), so a collision here
547
+ * is a collision of GOVERNED IDENTITY, not just of an authoring label. Walks
548
+ * the same shapes validateChromeConfig itself walks. Pure.
549
+ */
550
+ function collectChromeActionableIds(config) {
551
+ const out = [];
552
+ const push = (id, path) => {
553
+ if (typeof id === "string" && id.trim()) out.push({ id: id.trim(), path });
554
+ };
555
+ const header = config?.header;
556
+ if (header && typeof header === "object" && !Array.isArray(header)) {
557
+ for (const [i, n] of (Array.isArray(header.nav) ? header.nav : []).entries()) {
558
+ if (n == null || typeof n !== "object") continue;
559
+ push(n.id, `header.nav[${i}]`);
560
+ for (const [j, c] of (Array.isArray(n.children) ? n.children : []).entries()) {
561
+ if (c && typeof c === "object") push(c.id, `header.nav[${i}].children[${j}]`);
562
+ }
563
+ for (const [j, col] of (Array.isArray(n.columns) ? n.columns : []).entries()) {
564
+ if (col == null || typeof col !== "object") continue;
565
+ for (const [k, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
566
+ if (l && typeof l === "object") push(l.id, `header.nav[${i}].columns[${j}].links[${k}]`);
567
+ }
568
+ }
569
+ }
570
+ for (const [i, c] of (Array.isArray(header.ctas) ? header.ctas : []).entries()) {
571
+ if (c && typeof c === "object") push(c.id, `header.ctas[${i}]`);
572
+ }
573
+ if (header.memberCue && typeof header.memberCue === "object") push(header.memberCue.id, "header.memberCue");
574
+ const u = header.utilityNav;
575
+ if (u && typeof u === "object" && !Array.isArray(u)) {
576
+ for (const [i, l] of (Array.isArray(u.links) ? u.links : []).entries()) {
577
+ if (l && typeof l === "object") push(l.id, `header.utilityNav.links[${i}]`);
578
+ }
579
+ if (u.memberCue && typeof u.memberCue === "object") push(u.memberCue.id, "header.utilityNav.memberCue");
580
+ }
581
+ }
582
+ const footer = config?.footer;
583
+ if (footer && typeof footer === "object" && !Array.isArray(footer)) {
584
+ for (const [i, col] of (Array.isArray(footer.columns) ? footer.columns : []).entries()) {
585
+ if (col == null || typeof col !== "object") continue;
586
+ for (const [j, l] of (Array.isArray(col.links) ? col.links : []).entries()) {
587
+ if (l && typeof l === "object") push(l.id, `footer.columns[${i}].links[${j}]`);
588
+ }
589
+ }
590
+ }
591
+ return out;
592
+ }
593
+
594
+ /** `duplicate-cta`: two or more actionable items in one ChromeConfig resolve
595
+ * to the SAME governed identity. Almost always a copy-paste authoring
596
+ * mistake — the click listener's `closest("[data-tot-el]")` match and every
597
+ * downstream analytics query assume one element per id, so a duplicate
598
+ * silently merges two distinct interactions' data. WARN, never blocks: the
599
+ * page still renders and both elements still work.
600
+ */
601
+ function findDuplicateCtaIds(config, file) {
602
+ const byId = new Map();
603
+ for (const { id, path } of collectChromeActionableIds(config)) {
604
+ if (!byId.has(id)) byId.set(id, []);
605
+ byId.get(id).push(path);
606
+ }
607
+ const out = [];
608
+ for (const [id, paths] of byId) {
609
+ if (paths.length > 1) {
610
+ out.push(
611
+ mk(WARN, "duplicate-cta", file,
612
+ `id "${id}" is declared on ${paths.length} actionable items (${paths.join(", ")}) — data-tot-el must be unique per element; the click listener's closest() match and downstream analytics both assume one`,
613
+ "give every occurrence but one a distinct id"),
614
+ );
615
+ }
616
+ }
617
+ return out;
618
+ }
619
+
620
+ /**
621
+ * `cta-id-drift`: a governed actionable item's `data-tot-el` changed value
622
+ * between two versions of the SAME chrome.json, matched by structural
623
+ * position (e.g. "header.ctas[0]") — the same slot in both versions. Per
624
+ * dt-contract-v1's ledger `data-tot-el` is a PERMANENT identity ("a change
625
+ * here is DRIFT and is a defect"), unlike `data-tot-placement` which is
626
+ * expected to move on redesign.
627
+ *
628
+ * `previous` is an OPTIONAL caller-supplied prior chrome.json (already
629
+ * parsed) — this static, single-snapshot validator has no version history of
630
+ * its own, and no existing publish/preview utility in this repo diffs the
631
+ * CTA IDENTITY LIST across versions (apps/storefront/src/lib/publish and
632
+ * .../preview diff release ARTIFACT BYTES for the release panel, never this)
633
+ * to hook into instead. A version-aware caller (a future publish-time check,
634
+ * `tot validate --against <ref>`) supplies `previous`; this run alone never
635
+ * invents one, so omitting it is a silent no-op, not a missing check.
636
+ *
637
+ * A reorder/insert that shifts array positions can produce a false
638
+ * positive/negative here (position, not the item's own identity, is the
639
+ * match key) — a known limitation of matching without a stronger anchor.
640
+ */
641
+ function findCtaIdDrift(previous, current, file) {
642
+ if (previous == null || typeof previous !== "object") return [];
643
+ const before = new Map(collectChromeActionableIds(previous).map((e) => [e.path, e.id]));
644
+ const out = [];
645
+ for (const { id, path } of collectChromeActionableIds(current)) {
646
+ const priorId = before.get(path);
647
+ if (priorId !== undefined && priorId !== id) {
648
+ out.push(
649
+ mk(WARN, "cta-id-drift", file,
650
+ `${path}.id changed from "${priorId}" to "${id}" — data-tot-el is a permanent identity; a change here is drift, not a redesign, and breaks continuity with everything already recorded under the old id`,
651
+ "keep the original id (a deliberate identity migration is a separate, tracked decision, not an incidental edit)"),
652
+ );
653
+ }
654
+ }
655
+ return out;
656
+ }
657
+
658
+ // Compact mirror of @tot/public-runtime tracking-id-derivation.ts's trackable-
659
+ // tag set and `isWellFormedTrackingId` shape check — this script can't import
660
+ // that TS package at runtime (same published-CLI/no-build-step constraint as
661
+ // the chrome-shape mirrors above). Deliberately simplified: full structural
662
+ // derivation (region path, confidence) needs a real parser and an ancestor
663
+ // stack; this is a flat regex tag scan (the same style as HREF_RE/SRC_RE
664
+ // above), so it only answers "does this element carry a well-formed
665
+ // data-tot-el at all", not "what would a good one look like".
666
+ const ACTIONABLE_TAG_RE = /<([a-z][a-z0-9-]*)\b([^>]*)>/gi;
667
+ const TRACKABLE_HTML_TAGS = new Set(["a", "area", "button", "details", "summary", "form", "input", "select", "textarea", "label"]);
668
+ const TRACKABLE_HTML_ROLES = new Set(["button", "link", "tab", "menuitem", "switch", "checkbox"]);
669
+
670
+ function htmlAttr(attrs, name) {
671
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i").exec(attrs);
672
+ return m ? m[1] : undefined;
673
+ }
674
+
675
+ /** Mirror of tracking-id-derivation.ts's `isTrackableNode`: an `<a>`/`<area>`
676
+ * needs a real `href`; the fixed tag set is always a candidate; anything
677
+ * else needs an interactive ARIA role. */
678
+ function isTrackableHtmlTag(tag, attrs) {
679
+ if (tag === "a" || tag === "area") {
680
+ const href = htmlAttr(attrs, "href");
681
+ return typeof href === "string" && href.trim().length > 0;
682
+ }
683
+ if (TRACKABLE_HTML_TAGS.has(tag)) return true;
684
+ const role = htmlAttr(attrs, "role");
685
+ return typeof role === "string" && TRACKABLE_HTML_ROLES.has(role.trim().toLowerCase());
686
+ }
687
+
688
+ /** Mirror of tracking-id-derivation.ts's `isWellFormedTrackingId`: lowercase
689
+ * kebab tokens, non-empty, within the same 64-char budget. */
690
+ function isWellFormedCtaId(el) {
691
+ return typeof el === "string" && el.length > 0 && el.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(el);
692
+ }
693
+
694
+ /**
695
+ * `cta-missing-id`: a tenant-authored HTML fragment's actionable element
696
+ * carries no well-formed `data-tot-el`. Tenant fragments ship NO `data-tot-*`
697
+ * at all pre-derivation (docs/architecture/interaction-tracking-attribute-
698
+ * contract.md "Deriving data-tot-el") — `validateChromeConfig`'s hard
699
+ * either/or failure only reaches the SCHEMA-GOVERNED chrome.json path, never
700
+ * a raw fragment, so an un-identified CTA here would otherwise ship silently
701
+ * unmeasurable: the page renders fine, every other check passes, and the
702
+ * element is simply invisible to interaction tracking. WARN, never blocks.
703
+ */
704
+ function findMissingCtaIds(html, file) {
705
+ const out = [];
706
+ ACTIONABLE_TAG_RE.lastIndex = 0;
707
+ let m;
708
+ while ((m = ACTIONABLE_TAG_RE.exec(html))) {
709
+ const tag = m[1].toLowerCase();
710
+ const attrs = m[2] || "";
711
+ if (!isTrackableHtmlTag(tag, attrs)) continue;
712
+ const totEl = htmlAttr(attrs, "data-tot-el");
713
+ if (isWellFormedCtaId(totEl)) continue;
714
+ out.push(
715
+ mk(WARN, "cta-missing-id", file,
716
+ `a <${tag}> actionable element has ${totEl ? `a malformed data-tot-el (${JSON.stringify(totEl)})` : "no data-tot-el"} — it renders and functions but is invisible to interaction tracking`,
717
+ CTA_MISSING_ID_FIX),
718
+ );
719
+ }
720
+ return out;
721
+ }
722
+
431
723
  // --- filesystem helpers ------------------------------------------------------
432
724
  function readJsonSafe(path) {
433
725
  try {
@@ -517,10 +809,13 @@ function resolvePlatformRouteOwnership(config, hostIsPlatform) {
517
809
  /**
518
810
  * Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
519
811
  * @param {string} tenantDir absolute path to the tenant dir
520
- * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
812
+ * @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace", previousChromeJson?:any}} [opts]
521
813
  * `mode` — "monorepo" (default): served by this platform, so commerce tenants own
522
814
  * the framework routes. "workspace": a standalone checkout, conservative about
523
815
  * platform-route ownership unless the config resolves the host (`hostPlatform`).
816
+ * `previousChromeJson` — an already-parsed PRIOR version of content/chrome.json,
817
+ * supplied by a version-aware caller, so the `cta-id-drift` check has something
818
+ * to compare against (see findCtaIdDrift above). Omitted ⇒ that check is a no-op.
524
819
  * @returns {{ok:boolean, findings:Finding[]}}
525
820
  */
526
821
  export function validateTenant(tenantDir, opts = {}) {
@@ -613,6 +908,11 @@ export function validateTenant(tenantDir, opts = {}) {
613
908
  findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
614
909
  } else if (name === "home.json") {
615
910
  findings.push(...validateHomeDoc(value, "content/home.json"));
911
+ } else if (name === "chrome.json") {
912
+ findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
913
+ if (opts.previousChromeJson != null) {
914
+ findings.push(...findCtaIdDrift(opts.previousChromeJson, value, "content/chrome.json"));
915
+ }
616
916
  }
617
917
  }
618
918
  }
@@ -663,6 +963,9 @@ export function validateTenant(tenantDir, opts = {}) {
663
963
  "use a bare <style> tag"),
664
964
  );
665
965
  }
966
+ // actionable elements with no (or malformed) governed identity — advisory,
967
+ // never blocking (see findMissingCtaIds above).
968
+ findings.push(...findMissingCtaIds(html, r));
666
969
 
667
970
  for (const m of html.matchAll(HREF_RE)) {
668
971
  findings.push(...checkLink(m[1].trim(), r, scope, pageTargets, platformRoutes.owns));