@bridge-ds/core 0.2.0 → 0.3.0

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.
@@ -100,12 +100,12 @@ function previewComment(input) {
100
100
  }
101
101
  lines.push("");
102
102
  const ctx = { branch: input.branch, pr: input.pr, sha: input.sha };
103
- const links = previewLinks(input.apps, ctx);
103
+ const previews = input.previews ?? resolveBranchPreviews(input.apps, ctx);
104
104
  lines.push("### Previews", "");
105
- if (links.length) {
105
+ if (previews.length) {
106
106
  lines.push("| App | Preview |", "| --- | --- |");
107
- for (const l of links) lines.push(`| ${l.label} | ${l.url} |`);
108
- const docs = previewUrl(input.apps.docs, ctx);
107
+ for (const p of previews) lines.push(`| ${p.label} | ${p.url ?? "Link in the Vercel comment on this pull request"} |`);
108
+ const docs = previews.find((p) => p.app === "docs")?.url;
109
109
  if (docs) {
110
110
  lines.push("");
111
111
  lines.push(`Tokens only, no reinstall: open your staging with \`?bridge-tokens=${new URL("tokens.css", docs).href}\` (the staging app must allow this origin, see docs/PREVIEWS.md).`);
@@ -115,6 +115,449 @@ function previewComment(input) {
115
115
  }
116
116
  return lines.join("\n") + "\n";
117
117
  }
118
+ function installCommandFromPreviewComment(body) {
119
+ if (!body || !body.trimStart().startsWith(PREVIEW_COMMENT_MARKER)) return null;
120
+ const m = /```(?:sh|bash|shell)[^\n]*\n([\s\S]*?)```/.exec(body);
121
+ if (!m) return null;
122
+ const command = m[1].split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).join(" ").replace(/\s*\\\s+/g, " ").replace(/\s+/g, " ").trim();
123
+ return command || null;
124
+ }
125
+ function readPreviewComment(body) {
126
+ if (!body || !body.trimStart().startsWith(PREVIEW_COMMENT_MARKER)) return null;
127
+ const command = installCommandFromPreviewComment(body);
128
+ const sha7 = /Snapshot of `([0-9a-f]{7,40})`/.exec(body)?.[1]?.slice(0, 7) ?? null;
129
+ const source = !command ? "none" : command.includes("https://pkg.pr.new/") ? "pkg.pr.new" : /\/releases\/download\/preview-pr-\d+\//.test(command) ? "release" : "other";
130
+ return { command, sha7, source };
131
+ }
132
+ function findPreviewComment(comments) {
133
+ return [...comments].reverse().find((c) => !!c.body && c.body.trimStart().startsWith(PREVIEW_COMMENT_MARKER));
134
+ }
135
+ const httpsUrl = (raw) => {
136
+ if (!raw || typeof raw !== "string") return null;
137
+ try {
138
+ const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
139
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ };
144
+ const decodeBase64 = (b64) => {
145
+ const bin = atob(b64.replace(/\s/g, ""));
146
+ const bytes = new Uint8Array(bin.length);
147
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
148
+ return new TextDecoder("utf-8").decode(bytes);
149
+ };
150
+ const isVercelComment = (body) => !!body && /^\[vc\]: #[^:\s]*:[A-Za-z0-9+/=]+/m.test(body);
151
+ function parseVercelComment(body) {
152
+ if (!body) return null;
153
+ const m = /^\[vc\]: #[^:\s]*:([A-Za-z0-9+/=]+)/m.exec(body);
154
+ if (!m) return null;
155
+ let data;
156
+ try {
157
+ data = JSON.parse(decodeBase64(m[1]));
158
+ } catch {
159
+ return null;
160
+ }
161
+ if (!data || !Array.isArray(data.projects)) return null;
162
+ return data.projects.filter((p) => p && typeof p.name === "string").map((p) => ({ name: p.name, previewUrl: httpsUrl(typeof p.previewUrl === "string" ? p.previewUrl : null), status: typeof p.nextCommitStatus === "string" ? p.nextCommitStatus : null }));
163
+ }
164
+ function findVercelComment(comments) {
165
+ return [...comments].reverse().find((c) => isVercelComment(c.body));
166
+ }
167
+ function templateHostPrefix(template) {
168
+ if (!template) return null;
169
+ const host = /^https?:\/\/([^/?#]+)/i.exec(template)?.[1];
170
+ if (!host) return null;
171
+ return host.split(".")[0].split("{")[0].toLowerCase();
172
+ }
173
+ function appForProject(project, apps) {
174
+ const name = project.trim().toLowerCase();
175
+ if (!name) return null;
176
+ const keys = Object.keys(PREVIEW_APP_LABELS);
177
+ const prefixes = keys.map((app) => [app, templateHostPrefix(apps[app])]);
178
+ const exact = prefixes.find(([, p]) => p === `${name}-git-` || p === name);
179
+ if (exact) return exact[0];
180
+ const loose = prefixes.filter(([, p]) => p !== null && p.startsWith(`${name}-`));
181
+ return loose.length === 1 ? loose[0][0] : null;
182
+ }
183
+ function projectOfLabel(label) {
184
+ const m = /^\s*(?:preview|vercel)\s*[\u2013\u2014-]\s*(.+?)\s*$/i.exec(label);
185
+ return m ? m[1] : null;
186
+ }
187
+ const STATE_OF = {
188
+ success: "ready",
189
+ inactive: "ready",
190
+ deployed: "ready",
191
+ ready: "ready",
192
+ ignored: "ready",
193
+ // Vercel skipped the build (nothing changed for this app): the branch alias serves the last one.
194
+ pending: "building",
195
+ queued: "building",
196
+ in_progress: "building",
197
+ building: "building",
198
+ initializing: "building",
199
+ failure: "failed",
200
+ error: "failed",
201
+ failed: "failed",
202
+ canceled: "failed"
203
+ };
204
+ const stateOf = (s) => STATE_OF[(s ?? "").toLowerCase()] ?? "unknown";
205
+ const isInspectorUrl = (u) => /^https:\/\/(?:www\.)?vercel\.com\//i.test(u);
206
+ function resolveBranchPreviews(apps, ctx, sources = {}) {
207
+ const seen = /* @__PURE__ */ new Map();
208
+ const at = (app) => seen.get(app) ?? (seen.set(app, {}), seen.get(app));
209
+ const offer = (app, url, urlSource, state, stateRank) => {
210
+ if (!app) return;
211
+ const s = at(app);
212
+ if (url && !s.url) Object.assign(s, { url, urlSource });
213
+ if (state !== "unknown" && (s.stateRank === void 0 || stateRank < s.stateRank)) Object.assign(s, { state, stateRank });
214
+ };
215
+ const contexts = /* @__PURE__ */ new Set();
216
+ for (const st of sources.statuses ?? []) {
217
+ if (contexts.has(st.context)) continue;
218
+ contexts.add(st.context);
219
+ const project = projectOfLabel(st.context);
220
+ if (!project) continue;
221
+ const url = httpsUrl(st.target_url);
222
+ offer(appForProject(project, apps), url && !isInspectorUrl(url) ? url : null, "status", stateOf(st.state), 0);
223
+ }
224
+ for (const d of sources.deployments ?? []) {
225
+ const project = projectOfLabel(d.environment);
226
+ const app = project ? appForProject(project, apps) : null;
227
+ if (!app) continue;
228
+ const url = httpsUrl(d.environmentUrl);
229
+ const s = at(app);
230
+ if (url && s.urlSource !== "deployment") Object.assign(s, { url, urlSource: "deployment" });
231
+ const state = stateOf(d.state);
232
+ if (state !== "unknown" && (s.stateRank === void 0 || 1 < s.stateRank)) Object.assign(s, { state, stateRank: 1 });
233
+ }
234
+ for (const p of parseVercelComment(findVercelComment(sources.comments ?? [])?.body) ?? []) offer(appForProject(p.name, apps), p.previewUrl, "vercel-comment", stateOf(p.status), 2);
235
+ const out = [];
236
+ for (const app of Object.keys(PREVIEW_APP_LABELS)) {
237
+ if (!apps[app]) continue;
238
+ const s = seen.get(app) ?? {};
239
+ const url = s.url ?? previewUrl(apps[app], ctx);
240
+ const source = s.url ? s.urlSource : url ? "template" : null;
241
+ out.push({ app, label: PREVIEW_APP_LABELS[app], url, state: s.state ?? "unknown", source });
242
+ }
243
+ return out;
244
+ }
245
+ const previewLinksOf = (previews) => previews.filter((p) => !!p.url).map((p) => ({ app: p.app, label: p.label, url: p.url }));
246
+ const previewsSettled = (previews) => previews.every((p) => !!p.url && (p.state === "ready" || p.state === "failed"));
247
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
248
+ const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
249
+ function parseOwnerEntry(raw) {
250
+ if (typeof raw !== "string") return null;
251
+ const s = raw.trim().replace(/^@/, "");
252
+ const slash = s.indexOf("/");
253
+ if (slash < 0) return LOGIN.test(s) ? { kind: "person", login: s } : null;
254
+ const org = s.slice(0, slash);
255
+ const slug = s.slice(slash + 1);
256
+ return LOGIN.test(org) && SLUG.test(slug) ? { kind: "team", org, slug } : null;
257
+ }
258
+ function formatOwnerEntry(e) {
259
+ return e.kind === "team" ? `@${e.org}/${e.slug}` : e.login;
260
+ }
261
+ const isTeamEntry = (raw) => parseOwnerEntry(raw)?.kind === "team";
262
+ const teamKey = (org, slug) => `@${org.toLowerCase()}/${slug.toLowerCase()}`;
263
+ function teamKeyOf(raw) {
264
+ const e = parseOwnerEntry(raw);
265
+ return e?.kind === "team" ? teamKey(e.org, e.slug) : void 0;
266
+ }
267
+ function ownerEntryProblems(raw) {
268
+ if (raw === void 0) return [];
269
+ const isObj2 = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
270
+ if (!isObj2(raw)) return ['"governance.owners" must be { "design": [people or teams], "tech": [people or teams] }'];
271
+ const out = [];
272
+ for (const [k, v] of Object.entries(raw)) {
273
+ if (k !== "design" && k !== "tech") {
274
+ out.push(`"governance.owners.${k}" is not an owner group (design, tech)`);
275
+ continue;
276
+ }
277
+ if (!Array.isArray(v)) {
278
+ out.push(`"governance.owners.${k}" must be a list of people or teams`);
279
+ continue;
280
+ }
281
+ v.forEach((x, i) => {
282
+ if (typeof x !== "string" || !parseOwnerEntry(x))
283
+ out.push(`"governance.owners.${k}[${i}]" (${JSON.stringify(x)}) is not a GitHub login or team. Write a person as "peterbono" or "@peterbono", a team as "@org/team-slug".`);
284
+ });
285
+ }
286
+ return out;
287
+ }
288
+ function teamUnreadableMessage(team, reason) {
289
+ const name = `@${team.org}/${team.slug}`;
290
+ switch (reason) {
291
+ case "personal-account":
292
+ return `${name} cannot be used: ${team.org} is a personal GitHub account, and teams only exist in organizations. List people instead, or move the repository to an organization.`;
293
+ case "missing-permission":
294
+ return `The members of ${name} cannot be read: the GitHub token needs read:org (classic token) or Members: Read on the ${team.org} organization (fine-grained token).`;
295
+ case "no-token":
296
+ return `The members of ${name} cannot be read without a working GitHub token. Connect GitHub with a token that has read:org.`;
297
+ case "not-found":
298
+ return `${name} was not found. Check the team slug on GitHub, or give the token read:org so it can see the team.`;
299
+ case "unreachable":
300
+ return `The members of ${name} cannot be read right now: GitHub did not answer. Try again in a moment.`;
301
+ }
302
+ }
303
+ function teamsIn(owners) {
304
+ const all = Array.isArray(owners) ? owners : [...owners.design ?? [], ...owners.tech ?? []];
305
+ const out = /* @__PURE__ */ new Map();
306
+ for (const raw of all) {
307
+ const e = parseOwnerEntry(raw);
308
+ if (e?.kind === "team") out.set(teamKey(e.org, e.slug), { key: teamKey(e.org, e.slug), org: e.org, slug: e.slug });
309
+ }
310
+ return [...out.values()];
311
+ }
312
+ const norm$1 = (x) => x.trim().toLowerCase().replace(/^@/, "");
313
+ function expandEntries(entries, teams) {
314
+ const out = [];
315
+ const seen = /* @__PURE__ */ new Set();
316
+ const add = (x) => {
317
+ const k = norm$1(x);
318
+ if (k && !seen.has(k)) {
319
+ seen.add(k);
320
+ out.push(x.replace(/^@(?=[^/]+$)/, ""));
321
+ }
322
+ };
323
+ for (const raw of entries) {
324
+ const e = parseOwnerEntry(raw);
325
+ if (!e) continue;
326
+ if (e.kind === "person") add(e.login);
327
+ else {
328
+ const state = teams?.[teamKey(e.org, e.slug)];
329
+ if (state?.status === "ok") state.members.forEach(add);
330
+ else add(teamKey(e.org, e.slug));
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ function expandOwners(owners, teams) {
336
+ return { design: expandEntries(owners.design, teams), tech: expandEntries(owners.tech, teams) };
337
+ }
338
+ const people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
339
+ function entryLabel(raw, teams) {
340
+ const e = parseOwnerEntry(raw);
341
+ if (!e) return raw;
342
+ if (e.kind === "person") return `@${e.login}`;
343
+ const state = teams?.[teamKey(e.org, e.slug)];
344
+ const name = `@${e.org}/${e.slug}`;
345
+ return state?.status === "ok" ? `${name} (${people(state.members.length)})` : name;
346
+ }
347
+ function entriesFor(entries, logins, teams) {
348
+ const want = new Set(logins.map(norm$1));
349
+ const out = [];
350
+ for (const raw of entries) {
351
+ const e = parseOwnerEntry(raw);
352
+ if (!e) continue;
353
+ if (e.kind === "person") {
354
+ if (want.has(norm$1(e.login))) out.push(entryLabel(raw, teams));
355
+ continue;
356
+ }
357
+ const state = teams?.[teamKey(e.org, e.slug)];
358
+ if (state?.status !== "ok" || state.members.some((m) => want.has(norm$1(m)))) out.push(entryLabel(raw, teams));
359
+ }
360
+ return [...new Set(out)];
361
+ }
362
+ function unreadableTeams(teams) {
363
+ return Object.values(teams ?? {}).filter((t) => t.status === "unreadable");
364
+ }
365
+ class TeamDirectory {
366
+ constructor(reader, opts = {}) {
367
+ this.reader = reader;
368
+ this.ttl = opts.ttlMs ?? 5 * 6e4;
369
+ this.now = opts.now ?? Date.now;
370
+ }
371
+ reader;
372
+ teams = /* @__PURE__ */ new Map();
373
+ accounts = /* @__PURE__ */ new Map();
374
+ memberships = /* @__PURE__ */ new Map();
375
+ ttl;
376
+ now;
377
+ accountType(org) {
378
+ const k = org.toLowerCase();
379
+ let p = this.accounts.get(k);
380
+ if (!p) {
381
+ p = this.reader.getAccountType(org).catch(() => null);
382
+ this.accounts.set(k, p);
383
+ }
384
+ return p;
385
+ }
386
+ /** The reason a read of `org` failed, as a state. A 404 on a personal account says so; a 403 is a missing permission. */
387
+ async failure(ref, err) {
388
+ const e = err ?? {};
389
+ let reason;
390
+ if (!this.reader.hasToken) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "no-token";
391
+ else if (e.status === 401) reason = "no-token";
392
+ else if (e.status === 403 || e.auth === "missing-permission") reason = "missing-permission";
393
+ else if (e.status === 404) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "not-found";
394
+ else reason = "unreachable";
395
+ return { ...ref, status: "unreadable", reason, message: teamUnreadableMessage(ref, reason) };
396
+ }
397
+ /** Members of one team, or why they cannot be read. Cached. */
398
+ team(org, slug) {
399
+ const ref = { key: teamKey(org, slug), org, slug };
400
+ const hit = this.teams.get(ref.key);
401
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
402
+ const value = (async () => {
403
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
404
+ try {
405
+ const members = await this.reader.listTeamMembers(org, slug);
406
+ return { ...ref, status: "ok", members };
407
+ } catch (err) {
408
+ return this.failure(ref, err);
409
+ }
410
+ })();
411
+ this.teams.set(ref.key, { at: this.now(), value });
412
+ void value.then((s) => {
413
+ if (s.status === "unreadable" && s.reason === "unreachable") this.teams.delete(ref.key);
414
+ });
415
+ return value;
416
+ }
417
+ /** Every team named in the owner lists, read in parallel. Empty object when no team is named (no request). */
418
+ async resolve(owners) {
419
+ const refs = teamsIn(owners);
420
+ const states = await Promise.all(refs.map((r) => this.team(r.org, r.slug)));
421
+ return Object.fromEntries(states.map((s) => [s.key, s]));
422
+ }
423
+ /**
424
+ * Whether `login` is an active member of one team: from the cached member list when there is one, else one
425
+ * membership read (GET /orgs/{org}/teams/{slug}/memberships/{user}). An unreadable team returns its state.
426
+ */
427
+ async isMember(org, slug, login) {
428
+ const ref = { key: teamKey(org, slug), org, slug };
429
+ const cached = this.teams.get(ref.key);
430
+ if (cached && this.now() - cached.at < this.ttl) {
431
+ const s = await cached.value;
432
+ if (s.status === "ok") return s.members.some((m) => norm$1(m) === norm$1(login));
433
+ }
434
+ const k = `${ref.key}#${norm$1(login)}`;
435
+ const hit = this.memberships.get(k);
436
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
437
+ const value = (async () => {
438
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
439
+ try {
440
+ return await this.reader.getTeamMembership(org, slug, login) === "active";
441
+ } catch (err) {
442
+ return this.failure(ref, err);
443
+ }
444
+ })();
445
+ this.memberships.set(k, { at: this.now(), value });
446
+ return value;
447
+ }
448
+ /**
449
+ * Whether `login` holds a right given to `entries` (people and teams): a person entry matches directly, a team
450
+ * through its membership. Teams are only read when no person entry matches.
451
+ */
452
+ async memberOfAny(entries, login) {
453
+ const unreadable = [];
454
+ const parsed = entries.map(parseOwnerEntry).filter((e) => Boolean(e));
455
+ if (parsed.some((e) => e.kind === "person" && norm$1(e.login) === norm$1(login))) return { member: true, unreadable };
456
+ for (const e of parsed) {
457
+ if (e.kind !== "team") continue;
458
+ const r = await this.isMember(e.org, e.slug, login);
459
+ if (r === true) return { member: true, unreadable };
460
+ if (typeof r === "object" && r.status === "unreadable") unreadable.push(r);
461
+ }
462
+ return { member: false, unreadable };
463
+ }
464
+ /** The organization's teams for a picker, or why they cannot be listed (a personal account has none). */
465
+ async orgTeams(org) {
466
+ if (await this.accountType(org) === "User") return { status: "unreadable", reason: "personal-account" };
467
+ if (!this.reader.hasToken) return { status: "unreadable", reason: "no-token" };
468
+ try {
469
+ return { status: "ok", teams: await this.reader.listOrgTeams(org) };
470
+ } catch (err) {
471
+ const s = await this.failure({ key: teamKey(org, "*"), org, slug: "*" }, err);
472
+ return { status: "unreadable", reason: s.status === "unreadable" ? s.reason : "unreachable" };
473
+ }
474
+ }
475
+ /** Drops every cached read (after the token changes). */
476
+ clear() {
477
+ this.teams.clear();
478
+ this.accounts.clear();
479
+ this.memberships.clear();
480
+ }
481
+ }
482
+ const OWNER_GROUPS = ["design", "tech"];
483
+ const RIGHTS = ["approveDesign", "approveTech", "ship"];
484
+ const RIGHT_LABEL = {
485
+ approveDesign: "Approve design",
486
+ approveTech: "Approve tech",
487
+ ship: "Ship"
488
+ };
489
+ const DEFAULT_RIGHTS = { approveDesign: ["design"], approveTech: ["tech"], ship: ["tech"] };
490
+ const norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
491
+ const uniq = (xs) => [...new Set(xs)];
492
+ function normalizeRights(input) {
493
+ const pick = (r) => {
494
+ const v = input?.[r];
495
+ if (!Array.isArray(v)) return [...DEFAULT_RIGHTS[r]];
496
+ return OWNER_GROUPS.filter((g) => v.includes(g));
497
+ };
498
+ return { approveDesign: pick("approveDesign"), approveTech: pick("approveTech"), ship: pick("ship") };
499
+ }
500
+ function rightsProblems(raw) {
501
+ if (raw === void 0) return [];
502
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"governance.rights" must be { "approveDesign": [groups], "approveTech": [groups], "ship": [groups] }'];
503
+ const out = [];
504
+ for (const [k, v] of Object.entries(raw)) {
505
+ if (!RIGHTS.includes(k)) out.push(`"governance.rights.${k}" is not a right (${RIGHTS.join(", ")})`);
506
+ else if (!Array.isArray(v) || v.length === 0 || !v.every((g) => OWNER_GROUPS.includes(g)))
507
+ out.push(`"governance.rights.${k}" must list one or more of "design", "tech"`);
508
+ }
509
+ return out;
510
+ }
511
+ function holdersOf(right, owners, rights) {
512
+ const groups = (rights ?? DEFAULT_RIGHTS)[right];
513
+ return uniq(groups.flatMap((g) => owners[g] ?? []));
514
+ }
515
+ function approverLists(owners, rights, teams) {
516
+ const entries = approverEntries(owners, rights);
517
+ return teams === void 0 ? entries : expandOwners(entries, teams);
518
+ }
519
+ function approverEntries(owners, rights) {
520
+ if (!rights) return { design: [...owners.design], tech: [...owners.tech] };
521
+ return { design: holdersOf("approveDesign", owners, rights), tech: holdersOf("approveTech", owners, rights) };
522
+ }
523
+ function shipPolicy(owners, rights, teams) {
524
+ if (!rights) return null;
525
+ const entries = holdersOf("ship", owners, rights);
526
+ if (!entries.length) return null;
527
+ const logins = expandEntries(entries, teams);
528
+ return { logins, groups: [...rights.ship], entries, ...teams ? { teams } : {} };
529
+ }
530
+ function unreadableShipTeams(policy) {
531
+ if (!policy?.teams) return [];
532
+ const keys = new Set((policy.entries ?? []).map((e) => e.trim().toLowerCase().replace(/^@?/, "@")));
533
+ return unreadableTeams(policy.teams).filter((t) => keys.has(t.key));
534
+ }
535
+ function canShip(login, policy) {
536
+ if (!policy) return true;
537
+ if (!login || login.includes("/")) return false;
538
+ return policy.logins.map(norm).includes(norm(login));
539
+ }
540
+ function groupsPhrase(groups) {
541
+ const g = OWNER_GROUPS.filter((x) => groups.includes(x));
542
+ if (g.length === 1) return `the ${g[0]} owner`;
543
+ return `a ${g.join(" or ")} owner`;
544
+ }
545
+ function whoCanShipSentence(policy) {
546
+ const names = (policy.entries ?? policy.logins).map((l) => entryLabel(l, policy.teams)).join(", ");
547
+ return `Only ${groupsPhrase(policy.groups)} can ship this${names ? ` (${names})` : ""}.`;
548
+ }
549
+ const DEFAULT_VISUAL_IMPACT = { mode: "auto" };
550
+ const VISUAL_CHECK_LABEL = "visual-check";
551
+ function visualImpactProblems(raw) {
552
+ if (raw === void 0) return [];
553
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"visualImpact" must be { "mode": "auto" | "on-demand" }'];
554
+ const out = [];
555
+ for (const [k, v] of Object.entries(raw)) {
556
+ if (k !== "mode") out.push(`"visualImpact.${k}" is not a setting (mode)`);
557
+ else if (v !== "auto" && v !== "on-demand") out.push('"visualImpact.mode" must be "auto" or "on-demand"');
558
+ }
559
+ return out;
560
+ }
118
561
  const CONFIG_FILE = "bridge.config.json";
119
562
  const DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
120
563
  const DEFAULT_TOKEN_PATHS = {
@@ -251,9 +694,11 @@ function defineConfig(input) {
251
694
  requireReason: g.override.requireReason ?? DEFAULT_OVERRIDE.requireReason,
252
695
  minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
253
696
  }
254
- } : {}
697
+ } : {},
698
+ ...g.rights ? { rights: normalizeRights(g.rights) } : {}
255
699
  },
256
700
  ...input.audit ? { audit: { ...input.audit } } : {},
701
+ ...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
257
702
  ...input.integrations ? { integrations: { ...input.integrations } } : {},
258
703
  ...input.previews ? {
259
704
  previews: {
@@ -360,9 +805,7 @@ function loadConfig(source) {
360
805
  else {
361
806
  for (const k of ["protectedBranches", "labels", "guardrails"]) if (gov[k] !== void 0 && !(Array.isArray(gov[k]) && gov[k].every((x) => typeof x === "string"))) problems.push(`"governance.${k}" must be a list of strings`);
362
807
  if (gov.branchPrefix !== void 0 && !isStr(gov.branchPrefix)) problems.push('"governance.branchPrefix" must be a non-empty string');
363
- const o = gov.owners;
364
- if (o !== void 0 && (!isObj(o) || ["design", "tech"].some((k) => o[k] !== void 0 && !(Array.isArray(o[k]) && o[k].every((x) => typeof x === "string")))))
365
- problems.push('"governance.owners" must be { "design": [logins], "tech": [logins] }');
808
+ problems.push(...ownerEntryProblems(gov.owners));
366
809
  const ov = gov.override;
367
810
  if (ov !== void 0) {
368
811
  if (!isObj(ov)) problems.push('"governance.override" must be an object');
@@ -372,8 +815,10 @@ function loadConfig(source) {
372
815
  if (ov.minReasonLength !== void 0 && !(Number.isInteger(ov.minReasonLength) && ov.minReasonLength >= 0)) problems.push('"governance.override.minReasonLength" must be a whole number, 0 or more');
373
816
  }
374
817
  }
818
+ problems.push(...rightsProblems(gov.rights));
375
819
  }
376
820
  }
821
+ problems.push(...visualImpactProblems(d.visualImpact));
377
822
  const audit = d.audit;
378
823
  if (audit !== void 0) {
379
824
  if (!isObj(audit)) problems.push('"audit" must be an object');
@@ -419,7 +864,8 @@ function toConfigJson(config, schema = "./packages/core/bridge.config.schema.jso
419
864
  cssVar: config.cssVar,
420
865
  governance: config.governance,
421
866
  ...config.audit ? { audit: config.audit } : {},
422
- ...config.previews ? { previews: config.previews } : {}
867
+ ...config.previews ? { previews: config.previews } : {},
868
+ ...config.visualImpact ? { visualImpact: config.visualImpact } : {}
423
869
  };
424
870
  }
425
871
  class JsonParseError extends Error {
@@ -2480,7 +2926,7 @@ function parseComponentSpecs(text) {
2480
2926
  }
2481
2927
  const tokenCssVar = (path, prefix = "--", separator = "-") => `${prefix}${path.split(".").join(separator)}`;
2482
2928
  export {
2483
- checkLayeredPair as $,
2929
+ parseContrastChecks as $,
2484
2930
  removeToken as A,
2485
2931
  moveToken as B,
2486
2932
  COMPONENT_SPECS_VERSION as C,
@@ -2504,117 +2950,159 @@ export {
2504
2950
  DEFAULT_TOKEN_PATHS as U,
2505
2951
  applyTokenEdits as V,
2506
2952
  buildHistoryEntry as W,
2507
- tokensPath as X,
2508
- coerceValue as Y,
2509
- toConfigJson as Z,
2510
- parseContrastChecks as _,
2953
+ resolveBranchPreviews as X,
2954
+ tokensPath as Y,
2955
+ coerceValue as Z,
2956
+ toConfigJson as _,
2511
2957
  assertSafeBranch as a,
2512
- findToken as a$,
2513
- classifyRisk as a0,
2514
- formatChangeset as a1,
2515
- RISK_LABELS as a2,
2516
- parseJson as a3,
2517
- serializeJson as a4,
2518
- plainEqual as a5,
2519
- toPlain as a6,
2520
- getEntry as a7,
2521
- fromPlain as a8,
2522
- snapshotUrl as a9,
2523
- branchSlug as aA,
2524
- changeKind as aB,
2525
- changeTable as aC,
2526
- changedModes as aD,
2527
- changesetBump as aE,
2528
- classifyLayered as aF,
2529
- clearRawDeep as aG,
2530
- commitMessage as aH,
2531
- composeChangeset as aI,
2532
- composePrBody as aJ,
2533
- composeProposalBody as aK,
2534
- composite as aL,
2535
- contextFor as aM,
2536
- contrastDelta as aN,
2537
- contrastDeltaTable as aO,
2538
- contrastLevel as aP,
2539
- contrastPair as aQ,
2540
- contrastRatio as aR,
2541
- countChanges as aS,
2542
- defaultContext as aT,
2543
- defaultTitle as aU,
2544
- defineConfig as aV,
2545
- detectLayout as aW,
2546
- diffLayered as aX,
2547
- fieldChanges as aY,
2548
- fileModes as aZ,
2549
- filterHistory as a_,
2550
- tarballUrl as aa,
2551
- previewLinks as ab,
2552
- slugify as ac,
2553
- installCommand as ad,
2554
- AAA_MIN as ae,
2555
- AA_MIN as af,
2556
- BULK_LIMIT as ag,
2557
- CONTRAST_CELL_MAX as ah,
2558
- CONTRAST_CHECKS_FILE as ai,
2559
- DEFAULT_COMPONENT_SPECS_FILE as aj,
2560
- DEFAULT_GOVERNANCE as ak,
2561
- DEFAULT_OVERRIDE as al,
2562
- JsonParseError as am,
2563
- PREVIEW_APP_LABELS as an,
2564
- PREVIEW_COMMENT_MARKER as ao,
2565
- PREVIEW_PLACEHOLDERS as ap,
2566
- PRIMITIVE_MODE as aq,
2567
- RISK_TIER as ar,
2568
- SPEC_FIELDS as as,
2569
- TIER_ORDER as at,
2570
- UNRELEASED as au,
2571
- affectedContrast as av,
2572
- appUrl as aw,
2573
- assignVersions as ax,
2574
- baseBranchOf as ay,
2575
- branchName as az,
2958
+ contrastLevel as a$,
2959
+ checkLayeredPair as a0,
2960
+ classifyRisk as a1,
2961
+ formatChangeset as a2,
2962
+ RISK_LABELS as a3,
2963
+ parseJson as a4,
2964
+ serializeJson as a5,
2965
+ plainEqual as a6,
2966
+ toPlain as a7,
2967
+ getEntry as a8,
2968
+ fromPlain as a9,
2969
+ TeamDirectory as aA,
2970
+ UNRELEASED as aB,
2971
+ VISUAL_CHECK_LABEL as aC,
2972
+ affectedContrast as aD,
2973
+ appForProject as aE,
2974
+ appUrl as aF,
2975
+ approverEntries as aG,
2976
+ approverLists as aH,
2977
+ assignVersions as aI,
2978
+ baseBranchOf as aJ,
2979
+ branchName as aK,
2980
+ branchSlug as aL,
2981
+ canShip as aM,
2982
+ changeKind as aN,
2983
+ changeTable as aO,
2984
+ changedModes as aP,
2985
+ changesetBump as aQ,
2986
+ classifyLayered as aR,
2987
+ clearRawDeep as aS,
2988
+ commitMessage as aT,
2989
+ composeChangeset as aU,
2990
+ composePrBody as aV,
2991
+ composeProposalBody as aW,
2992
+ composite as aX,
2993
+ contextFor as aY,
2994
+ contrastDelta as aZ,
2995
+ contrastDeltaTable as a_,
2996
+ previewLinks as aa,
2997
+ slugify as ab,
2998
+ readPreviewComment as ac,
2999
+ findPreviewComment as ad,
3000
+ previewsSettled as ae,
3001
+ AAA_MIN as af,
3002
+ AA_MIN as ag,
3003
+ BULK_LIMIT as ah,
3004
+ CONTRAST_CELL_MAX as ai,
3005
+ CONTRAST_CHECKS_FILE as aj,
3006
+ DEFAULT_COMPONENT_SPECS_FILE as ak,
3007
+ DEFAULT_GOVERNANCE as al,
3008
+ DEFAULT_OVERRIDE as am,
3009
+ DEFAULT_RIGHTS as an,
3010
+ DEFAULT_VISUAL_IMPACT as ao,
3011
+ JsonParseError as ap,
3012
+ OWNER_GROUPS as aq,
3013
+ PREVIEW_APP_LABELS as ar,
3014
+ PREVIEW_COMMENT_MARKER as as,
3015
+ PREVIEW_PLACEHOLDERS as at,
3016
+ PRIMITIVE_MODE as au,
3017
+ RIGHTS as av,
3018
+ RIGHT_LABEL as aw,
3019
+ RISK_TIER as ax,
3020
+ SPEC_FIELDS as ay,
3021
+ TIER_ORDER as az,
2576
3022
  parseChangelog as b,
2577
- firstParagraph as b0,
2578
- flattenTokens as b1,
2579
- formatNumber as b2,
2580
- getChild as b3,
2581
- indexByPath as b4,
2582
- inheritedTypeAt as b5,
2583
- isGuardrail as b6,
2584
- isTokenFile as b7,
2585
- kindForMin as b8,
2586
- layeredImpact as b9,
2587
- tokenNode as bA,
2588
- valueIn as bB,
2589
- variantName as bC,
2590
- versionTimeline as bD,
2591
- luminance as ba,
2592
- modeLabel as bb,
2593
- nodesEqual as bc,
2594
- overrideKey as bd,
2595
- pairKey as be,
2596
- parseChangesetText as bf,
2597
- parseComponentSpecs as bg,
2598
- planRestore as bh,
2599
- planRevert as bi,
2600
- planToJson as bj,
2601
- previewComment as bk,
2602
- previewUrl as bl,
2603
- protectedBranchesOf as bm,
2604
- renderedColor as bn,
2605
- resolve as bo,
2606
- runLayeredContrast as bp,
2607
- sameToken as bq,
2608
- specHash as br,
2609
- summarizeTokenChange as bs,
2610
- tarballAssetName as bt,
2611
- threeWayDiff as bu,
2612
- titleOf as bv,
2613
- toChangeSummary as bw,
2614
- toMap as bx,
2615
- tokenBump as by,
2616
- tokenHistory as bz,
3023
+ tarballUrl as b$,
3024
+ contrastPair as b0,
3025
+ contrastRatio as b1,
3026
+ countChanges as b2,
3027
+ defaultContext as b3,
3028
+ defaultTitle as b4,
3029
+ defineConfig as b5,
3030
+ detectLayout as b6,
3031
+ diffLayered as b7,
3032
+ entriesFor as b8,
3033
+ entryLabel as b9,
3034
+ nodesEqual as bA,
3035
+ normalizeRights as bB,
3036
+ overrideKey as bC,
3037
+ ownerEntryProblems as bD,
3038
+ pairKey as bE,
3039
+ parseChangesetText as bF,
3040
+ parseComponentSpecs as bG,
3041
+ parseOwnerEntry as bH,
3042
+ parseVercelComment as bI,
3043
+ planRestore as bJ,
3044
+ planRevert as bK,
3045
+ planToJson as bL,
3046
+ previewComment as bM,
3047
+ previewLinksOf as bN,
3048
+ previewUrl as bO,
3049
+ projectOfLabel as bP,
3050
+ protectedBranchesOf as bQ,
3051
+ renderedColor as bR,
3052
+ resolve as bS,
3053
+ rightsProblems as bT,
3054
+ runLayeredContrast as bU,
3055
+ sameToken as bV,
3056
+ shipPolicy as bW,
3057
+ snapshotUrl as bX,
3058
+ specHash as bY,
3059
+ summarizeTokenChange as bZ,
3060
+ tarballAssetName as b_,
3061
+ expandEntries as ba,
3062
+ expandOwners as bb,
3063
+ fieldChanges as bc,
3064
+ fileModes as bd,
3065
+ filterHistory as be,
3066
+ findToken as bf,
3067
+ findVercelComment as bg,
3068
+ firstParagraph as bh,
3069
+ flattenTokens as bi,
3070
+ formatNumber as bj,
3071
+ formatOwnerEntry as bk,
3072
+ getChild as bl,
3073
+ groupsPhrase as bm,
3074
+ holdersOf as bn,
3075
+ indexByPath as bo,
3076
+ inheritedTypeAt as bp,
3077
+ installCommand as bq,
3078
+ installCommandFromPreviewComment as br,
3079
+ isGuardrail as bs,
3080
+ isTeamEntry as bt,
3081
+ isTokenFile as bu,
3082
+ isVercelComment as bv,
3083
+ kindForMin as bw,
3084
+ layeredImpact as bx,
3085
+ luminance as by,
3086
+ modeLabel as bz,
2617
3087
  collectHistory as c,
3088
+ teamKey as c0,
3089
+ teamKeyOf as c1,
3090
+ teamUnreadableMessage as c2,
3091
+ teamsIn as c3,
3092
+ threeWayDiff as c4,
3093
+ titleOf as c5,
3094
+ toChangeSummary as c6,
3095
+ toMap as c7,
3096
+ tokenBump as c8,
3097
+ tokenHistory as c9,
3098
+ tokenNode as ca,
3099
+ unreadableShipTeams as cb,
3100
+ unreadableTeams as cc,
3101
+ valueIn as cd,
3102
+ variantName as ce,
3103
+ versionTimeline as cf,
3104
+ visualImpactProblems as cg,
3105
+ whoCanShipSentence as ch,
2618
3106
  buildReport as d,
2619
3107
  prNumberFromSubject as e,
2620
3108
  CONFIG_FILE as f,
@@ -2639,4 +3127,4 @@ export {
2639
3127
  contextLabel as y,
2640
3128
  pathId as z
2641
3129
  };
2642
- //# sourceMappingURL=component-spec-B0V_D3gM.js.map
3130
+ //# sourceMappingURL=component-spec-IRFGJ7XK.js.map