@tokenoftrust/cli 2.0.2 → 2.0.4
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 +1 -1
- package/src/commands/submit.mjs +9 -27
- package/src/commands/validate.mjs +7 -1
- package/src/machine-id.mjs +72 -0
- package/src/telemetry.mjs +126 -0
- package/src/validate.mjs +304 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
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",
|
package/src/commands/submit.mjs
CHANGED
|
@@ -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
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stable per-machine identifier, persisted in `~/.tot/config.json` alongside
|
|
3
|
+
* whatever else already lives there. Read/write is additive — we merge just the
|
|
4
|
+
* `machineId` key in, so other tools' fields (e.g. `runtimes`) ride along
|
|
5
|
+
* untouched, same philosophy as token-store's schema-free credential file.
|
|
6
|
+
*
|
|
7
|
+
* Not a secret, but kept 0600 in a 0700 dir like the rest of `~/.tot` for
|
|
8
|
+
* consistency. `TOT_HOME` overrides the home dir (tests point it at a temp dir),
|
|
9
|
+
* like activity-log and token-store.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by contract: never throws. A persist failure still returns a
|
|
12
|
+
* usable (if unpersisted) id for the current call.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { homedir, hostname } from "node:os";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Absolute path to the machine-level config for this environment. */
|
|
22
|
+
export function configPath(env = process.env) {
|
|
23
|
+
const home = env.TOT_HOME || homedir();
|
|
24
|
+
return join(home, ".tot", "config.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the parsed config, or {} if absent/unreadable/malformed. Never throws. */
|
|
28
|
+
export function readConfig(env = process.env) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
|
|
31
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge `patch` into the existing config and write it back atomically (temp-file
|
|
39
|
+
* rename, 0600), preserving keys `patch` doesn't mention. Returns the merged
|
|
40
|
+
* config, or null on failure. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function writeConfig(patch, env = process.env) {
|
|
43
|
+
try {
|
|
44
|
+
const filePath = configPath(env);
|
|
45
|
+
const merged = { ...readConfig(env), ...patch };
|
|
46
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
47
|
+
const tmp = `${filePath}.tmp`;
|
|
48
|
+
writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
49
|
+
renameSync(tmp, filePath);
|
|
50
|
+
chmodSync(filePath, 0o600);
|
|
51
|
+
return merged;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This machine's stable id: read from config if present, else generate a UUID
|
|
59
|
+
* and persist it for next time. Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export function ensureMachineId(env = process.env) {
|
|
62
|
+
const existing = readConfig(env).machineId;
|
|
63
|
+
if (typeof existing === "string" && existing) return existing;
|
|
64
|
+
const id = randomUUID();
|
|
65
|
+
const written = writeConfig({ machineId: id }, env);
|
|
66
|
+
return (written && written.machineId) || id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** This machine's hostname (not persisted — just os.hostname()). */
|
|
70
|
+
export function host() {
|
|
71
|
+
return hostname();
|
|
72
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ops telemetry — a durable, queryable record of fold/reap/sync/deploy stage
|
|
3
|
+
* transitions, so "what's slow / what's failing / what keeps needing a human" is
|
|
4
|
+
* answerable without a manual digging session after the fact.
|
|
5
|
+
*
|
|
6
|
+
* A SIBLING to activity-log.mjs, not an extension of it: activity-log is a
|
|
7
|
+
* 300-line RING (correct for its feedback-breadcrumb use case — bounded, most
|
|
8
|
+
* recent wins). Telemetry needs the opposite shape — unbounded, longitudinal
|
|
9
|
+
* history — so it uses a separate, date-partitioned, append-only stream:
|
|
10
|
+
* `~/.tot/telemetry/YYYY-MM.jsonl` (one file per UTC calendar month, partitioned
|
|
11
|
+
* by the entry's own `ts`), never truncated. Retention is "delete whole expired
|
|
12
|
+
* partitions" (see pruneOldPartitions), not the ring's overwrite-oldest-entry.
|
|
13
|
+
*
|
|
14
|
+
* Kept deliberately generic and self-describing: no coupling to
|
|
15
|
+
* workstream_analytics' event-journal shape. Callers own their own event schema
|
|
16
|
+
* (op/stage/reason codes are a separate concern — see the git-ops-telemetry
|
|
17
|
+
* handoff's U2 — not this module's).
|
|
18
|
+
*
|
|
19
|
+
* Append strategy differs from activity-log's temp+rename-the-whole-file (right
|
|
20
|
+
* for a small bounded ring, wrong here — it would mean rewriting a growing
|
|
21
|
+
* multi-KB month file on every single stage transition). Instead this appends
|
|
22
|
+
* with a single O_APPEND write() per entry: POSIX guarantees a single write()
|
|
23
|
+
* below PIPE_BUF is atomic, so concurrent writers (several git-ops scripts
|
|
24
|
+
* running at once, on one machine) can't interleave partial lines.
|
|
25
|
+
*
|
|
26
|
+
* NEVER throws / never blocks the caller — same best-effort contract as
|
|
27
|
+
* activity-log. `TOT_HOME` overrides the home dir (tests).
|
|
28
|
+
*
|
|
29
|
+
* A parallel POSIX-shell implementation lives at `scripts/lib/telemetry.sh` for
|
|
30
|
+
* the bash git-ops scripts this feeds — shelling out to `node` per stage
|
|
31
|
+
* transition would add measurement noise to the durations being measured. The
|
|
32
|
+
* two are separate, from-scratch implementations (no way to share code across
|
|
33
|
+
* languages); `scripts/lib/telemetry-cross-impl.test.mjs` exercises both against
|
|
34
|
+
* the same inputs and asserts schema-identical output as a drift guard. Any
|
|
35
|
+
* change to the on-disk shape here must be mirrored there.
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
appendFileSync, mkdirSync, chmodSync, readdirSync, rmSync, existsSync, readFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { redactArgs } from "./activity-log.mjs";
|
|
43
|
+
import { ensureMachineId, host } from "./machine-id.mjs";
|
|
44
|
+
|
|
45
|
+
/** Keep telemetry partitions for this many days; whole months older than this are pruned. */
|
|
46
|
+
export const RETENTION_DAYS = 90;
|
|
47
|
+
|
|
48
|
+
/** Directory holding all telemetry partitions for this environment. */
|
|
49
|
+
export function telemetryDir(env = process.env) {
|
|
50
|
+
const home = env.TOT_HOME || homedir();
|
|
51
|
+
return join(home, ".tot", "telemetry");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `YYYY-MM` partition key for a Date, in UTC. */
|
|
55
|
+
export function monthKey(date) {
|
|
56
|
+
return date.toISOString().slice(0, 7);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Absolute path to the partition file a given Date's entries land in. */
|
|
60
|
+
export function telemetryPath(date, env = process.env) {
|
|
61
|
+
return join(telemetryDir(env), `${monthKey(date)}.jsonl`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stamped(entry, env) {
|
|
65
|
+
const out = { ...entry };
|
|
66
|
+
if (!out.ts) out.ts = new Date().toISOString();
|
|
67
|
+
if (!out.host) out.host = host();
|
|
68
|
+
if (!out.machineId) out.machineId = ensureMachineId(env);
|
|
69
|
+
if (Array.isArray(out.args)) out.args = redactArgs(out.args);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Delete partitions wholly older than `retentionDays`. Cheap to run on every
|
|
75
|
+
* write: partition files number in the dozens at most (one per month), so a
|
|
76
|
+
* full directory listing per call is fine — no separate prune entrypoint or
|
|
77
|
+
* schedule to forget to run.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneOldPartitions(env = process.env, { retentionDays = RETENTION_DAYS, now = new Date() } = {}) {
|
|
80
|
+
try {
|
|
81
|
+
const dir = telemetryDir(env);
|
|
82
|
+
if (!existsSync(dir)) return;
|
|
83
|
+
const cutoff = monthKey(new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000));
|
|
84
|
+
for (const name of readdirSync(dir)) {
|
|
85
|
+
const match = name.match(/^(\d{4}-\d{2})\.jsonl$/);
|
|
86
|
+
if (match && match[1] < cutoff) {
|
|
87
|
+
rmSync(join(dir, name), { force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* best-effort: never throw */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Append one telemetry entry to its month partition. Stamps `ts`/`host`/
|
|
97
|
+
* `machineId` when the caller omits them (never overrides an explicit value),
|
|
98
|
+
* redacts `args` if present (same secret flags as activity-log), and prunes
|
|
99
|
+
* expired partitions after a successful write. Returns the entry actually
|
|
100
|
+
* written, or null on failure. Never throws.
|
|
101
|
+
*/
|
|
102
|
+
export function recordTelemetry(entry, env = process.env) {
|
|
103
|
+
try {
|
|
104
|
+
const full = stamped(entry, env);
|
|
105
|
+
const dir = telemetryDir(env);
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
const filePath = telemetryPath(new Date(full.ts), env);
|
|
108
|
+
appendFileSync(filePath, `${JSON.stringify(full)}\n`, { mode: 0o600, flag: "a" });
|
|
109
|
+
chmodSync(filePath, 0o600);
|
|
110
|
+
pruneOldPartitions(env);
|
|
111
|
+
return full;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read parsed entries from one month's partition (default: current UTC month). Never throws. */
|
|
118
|
+
export function readTelemetry(env = process.env, { month = "" } = {}) {
|
|
119
|
+
const key = month || monthKey(new Date());
|
|
120
|
+
const filePath = join(telemetryDir(env), `${key}.jsonl`);
|
|
121
|
+
try {
|
|
122
|
+
return readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
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));
|