@bridge-ds/mcp 0.9.0 → 0.11.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.
@@ -119,7 +119,7 @@ var STATE_OF = {
119
119
  deployed: "ready",
120
120
  ready: "ready",
121
121
  ignored: "ready",
122
- // Vercel skipped the build (nothing changed for this app): the branch alias serves the last one.
122
+ // Vercel skipped the build: nothing to wait for. BranchPreview.skipped says the branch has no build of its own.
123
123
  pending: "building",
124
124
  queued: "building",
125
125
  in_progress: "building",
@@ -131,13 +131,15 @@ var STATE_OF = {
131
131
  canceled: "failed"
132
132
  };
133
133
  var stateOf = (s) => STATE_OF[(s ?? "").toLowerCase()] ?? "unknown";
134
+ var IGNORED_BUILD = /ignored build step/i;
134
135
  var isInspectorUrl = (u) => /^https:\/\/(?:www\.)?vercel\.com\//i.test(u);
135
136
  function resolveBranchPreviews(apps, ctx, sources = {}) {
136
137
  const seen = /* @__PURE__ */ new Map();
137
138
  const at = (app) => seen.get(app) ?? (seen.set(app, {}), seen.get(app));
138
- const offer = (app, url, urlSource, state, stateRank) => {
139
+ const offer = (app, url, urlSource, state, stateRank, skipped) => {
139
140
  if (!app) return;
140
141
  const s = at(app);
142
+ if (skipped !== void 0 && (s.skipRank === void 0 || stateRank < s.skipRank)) Object.assign(s, { skipped, skipRank: stateRank });
141
143
  if (url && !s.url) Object.assign(s, { url, urlSource });
142
144
  if (state !== "unknown" && (s.stateRank === void 0 || stateRank < s.stateRank)) Object.assign(s, { state, stateRank });
143
145
  };
@@ -148,7 +150,7 @@ function resolveBranchPreviews(apps, ctx, sources = {}) {
148
150
  const project = projectOfLabel(st.context);
149
151
  if (!project) continue;
150
152
  const url = httpsUrl(st.target_url);
151
- offer(appForProject(project, apps), url && !isInspectorUrl(url) ? url : null, "status", stateOf(st.state), 0);
153
+ offer(appForProject(project, apps), url && !isInspectorUrl(url) ? url : null, "status", stateOf(st.state), 0, IGNORED_BUILD.test(st.description ?? ""));
152
154
  }
153
155
  for (const d of sources.deployments ?? []) {
154
156
  const project = projectOfLabel(d.environment);
@@ -160,17 +162,18 @@ function resolveBranchPreviews(apps, ctx, sources = {}) {
160
162
  const state = stateOf(d.state);
161
163
  if (state !== "unknown" && (s.stateRank === void 0 || 1 < s.stateRank)) Object.assign(s, { state, stateRank: 1 });
162
164
  }
163
- for (const p of parseVercelComment(findVercelComment(sources.comments ?? [])?.body) ?? []) offer(appForProject(p.name, apps), p.previewUrl, "vercel-comment", stateOf(p.status), 2);
165
+ for (const p of parseVercelComment(findVercelComment(sources.comments ?? [])?.body) ?? []) offer(appForProject(p.name, apps), p.previewUrl, "vercel-comment", stateOf(p.status), 2, p.status ? p.status.toUpperCase() === "IGNORED" : void 0);
164
166
  const out = [];
165
167
  for (const app of Object.keys(PREVIEW_APP_LABELS)) {
166
168
  if (!apps[app]) continue;
167
169
  const s = seen.get(app) ?? {};
168
170
  const url = s.url ?? previewUrl(apps[app], ctx);
169
171
  const source = s.url ? s.urlSource : url ? "template" : null;
170
- out.push({ app, label: PREVIEW_APP_LABELS[app], url, state: s.state ?? "unknown", source });
172
+ out.push({ app, label: PREVIEW_APP_LABELS[app], url, state: s.state ?? "unknown", source, ...s.skipped ? { skipped: true } : {} });
171
173
  }
172
174
  return out;
173
175
  }
176
+ var previewLinksOf = (previews) => previews.filter((p) => !!p.url && !p.skipped).map((p) => ({ app: p.app, label: p.label, url: p.url }));
174
177
  var previewsSettled = (previews) => previews.every((p) => p.state === "failed" || !!p.url && p.state === "ready");
175
178
 
176
179
  // ../core/src/teams.ts
@@ -533,6 +536,27 @@ function productForPlace(products, place) {
533
536
  }
534
537
  return { product: defaultProduct(products), how: "default" };
535
538
  }
539
+ function productRepoOf(p, designSystemRepo) {
540
+ const repo = p?.repo?.trim();
541
+ if (!repo || !REPO.test(repo)) return null;
542
+ return repo.toLowerCase() === (designSystemRepo ?? "").trim().toLowerCase() ? null : repo;
543
+ }
544
+ function productRepos(products, designSystemRepo) {
545
+ const seen = /* @__PURE__ */ new Set();
546
+ const out = [];
547
+ for (const p of products) {
548
+ const repo = productRepoOf(p, designSystemRepo);
549
+ if (!repo || seen.has(repo.toLowerCase())) continue;
550
+ seen.add(repo.toLowerCase());
551
+ out.push({ product: p, repo });
552
+ }
553
+ return out;
554
+ }
555
+ function productOfRepo(products, repo, designSystemRepo) {
556
+ const r = (repo ?? "").trim().toLowerCase();
557
+ if (!r) return void 0;
558
+ return productRepos(products, designSystemRepo).find((x) => x.repo.toLowerCase() === r)?.product;
559
+ }
536
560
  var KEY = /^[a-z0-9][a-z0-9-]*$/;
537
561
  var REPO = /^[\w.-]+\/[\w.-]+$/;
538
562
  var FIELDS = ["key", "name", "repo", "path", "preview", "framework", "default"];
@@ -722,7 +746,8 @@ function defineConfig(input) {
722
746
  }
723
747
  } : {},
724
748
  ...g.rights ? { rights: normalizeRights(g.rights) } : {},
725
- ...g.soloOwnerCanApproveBoth === true ? { soloOwnerCanApproveBoth: true } : {}
749
+ ...g.soloOwnerCanApproveBoth === true ? { soloOwnerCanApproveBoth: true } : {},
750
+ ...g.leadsPublishTier1 === false ? { leadsPublishTier1: false } : {}
726
751
  },
727
752
  products: input.products?.length ? input.products.map((p) => ({ ...p })) : DEFAULT_PRODUCTS.map((p) => ({ ...p })),
728
753
  changes: input.changes === "direct" ? "direct" : DEFAULT_CHANGES_MODE,
@@ -850,6 +875,7 @@ function loadConfig(source) {
850
875
  }
851
876
  problems.push(...rightsProblems(gov.rights));
852
877
  if (gov.soloOwnerCanApproveBoth !== void 0 && typeof gov.soloOwnerCanApproveBoth !== "boolean") problems.push('"governance.soloOwnerCanApproveBoth" must be true or false');
878
+ if (gov.leadsPublishTier1 !== void 0 && typeof gov.leadsPublishTier1 !== "boolean") problems.push('"governance.leadsPublishTier1" must be true or false');
853
879
  }
854
880
  }
855
881
  problems.push(...visualImpactProblems(d.visualImpact));
@@ -1071,164 +1097,6 @@ function parseContrastChecks(text) {
1071
1097
  return out;
1072
1098
  }
1073
1099
 
1074
- // ../core/src/scopes.ts
1075
- var has = (segments, ...names) => names.some((n) => segments.includes(n));
1076
- function defaultScopes(path, type, tier) {
1077
- const s = path.split(".");
1078
- switch (type) {
1079
- case "color":
1080
- if (tier === "primitive" || tier === "brand" && s[0] === "palette") return [];
1081
- if (has(s, "text")) return ["TEXT_FILL"];
1082
- if (has(s, "border", "ring", "outline")) return ["STROKE_COLOR"];
1083
- if (has(s, "bg", "surface", "background")) return ["FRAME_FILL", "SHAPE_FILL"];
1084
- if (has(s, "shadow")) return ["EFFECT_COLOR"];
1085
- return ["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"];
1086
- case "dimension":
1087
- if (has(s, "radius")) return ["CORNER_RADIUS"];
1088
- if (has(s, "border", "width", "stroke")) return ["STROKE_FLOAT"];
1089
- if (has(s, "space", "spacing", "gap")) return ["GAP", "WIDTH_HEIGHT"];
1090
- if (has(s, "size") && has(s, "font")) return ["FONT_SIZE"];
1091
- if (has(s, "letter-spacing")) return ["LETTER_SPACING"];
1092
- return ["WIDTH_HEIGHT", "GAP"];
1093
- case "fontWeight":
1094
- return ["FONT_WEIGHT"];
1095
- case "fontFamily":
1096
- return ["FONT_FAMILY"];
1097
- case "number":
1098
- if (has(s, "line-height", "lineHeight")) return ["LINE_HEIGHT"];
1099
- if (has(s, "opacity")) return ["OPACITY"];
1100
- return ["WIDTH_HEIGHT", "GAP"];
1101
- case "string":
1102
- return ["TEXT_CONTENT"];
1103
- default:
1104
- return [];
1105
- }
1106
- }
1107
- var sameScopes = (a, b) => {
1108
- const sa = [...new Set(a)].sort();
1109
- const sb = [...new Set(b)].sort();
1110
- return sa.length === sb.length && sa.every((x, i) => x === sb[i]);
1111
- };
1112
- var effectiveScopes = (t) => t.scopes ?? defaultScopes(t.path, t.type, t.tier);
1113
-
1114
- // ../core/src/diff-engine.ts
1115
- var normDesc = (d) => (d ?? "").trim();
1116
- function fieldChanges(a, b) {
1117
- const out = [];
1118
- if (a.path !== b.path) out.push({ field: "path", before: a.path, after: b.path });
1119
- if (a.type !== b.type) out.push({ field: "type", before: a.type, after: b.type });
1120
- const modes = [.../* @__PURE__ */ new Set([...Object.keys(a.values), ...Object.keys(b.values)])];
1121
- for (const mode of modes) {
1122
- if (!valuesEqual(b.type, a.values[mode], b.values[mode])) out.push({ field: "value", mode, before: a.values[mode], after: b.values[mode] });
1123
- }
1124
- if (normDesc(a.description) !== normDesc(b.description)) out.push({ field: "description", before: a.description, after: b.description });
1125
- const sa = effectiveScopes(a);
1126
- const sb = effectiveScopes(b);
1127
- if (!sameScopes(sa, sb)) out.push({ field: "scopes", before: sa, after: sb });
1128
- return out;
1129
- }
1130
- function sameToken(a, b) {
1131
- if (!a || !b) return a === b;
1132
- return fieldChanges(a, b).length === 0;
1133
- }
1134
- function sideChange(from, to) {
1135
- if (!from && !to) return void 0;
1136
- if (!from) return { kind: "added", fields: [] };
1137
- if (!to) return { kind: "removed", fields: [] };
1138
- const fields = fieldChanges(from, to);
1139
- if (!fields.length) return void 0;
1140
- return { kind: fields.some((f) => f.field === "path") ? "renamed" : "changed", fields };
1141
- }
1142
- function threeWayDiff(base, figma, head) {
1143
- const ids = /* @__PURE__ */ new Set([...base?.keys() ?? [], ...figma.keys(), ...head.keys()]);
1144
- const result = { local: [], remote: [], conflicts: [], unchanged: 0 };
1145
- for (const id of [...ids].sort()) {
1146
- const b = base?.get(id);
1147
- const f = figma.get(id);
1148
- const h = head.get(id);
1149
- const local = sideChange(b, f);
1150
- const remote = sideChange(b, h);
1151
- if (!local && !remote || f && h && sameToken(f, h) || !f && !h) {
1152
- result.unchanged++;
1153
- continue;
1154
- }
1155
- const entry = { id, path: (f ?? h ?? b).path, base: b, figma: f, head: h, local, remote, conflict: false };
1156
- if (local && !remote) {
1157
- entry.path = (f ?? b).path;
1158
- result.local.push(entry);
1159
- } else if (remote && !local) {
1160
- entry.path = (h ?? b).path;
1161
- result.remote.push(entry);
1162
- } else {
1163
- entry.conflict = true;
1164
- result.conflicts.push(entry);
1165
- }
1166
- }
1167
- const byPath = (a, b) => a.path.localeCompare(b.path);
1168
- result.local.sort(byPath);
1169
- result.remote.sort(byPath);
1170
- result.conflicts.sort(byPath);
1171
- return result;
1172
- }
1173
-
1174
- // ../core/src/risk.ts
1175
- var RISK_LABELS = {
1176
- 1: "Tier 1: low risk, one code owner",
1177
- 2: "Tier 2: medium risk, design review then design + tech approval",
1178
- 3: "Tier 3: token change, needs design + tech approval"
1179
- };
1180
- function figmaPushTierLabel(tier, changes) {
1181
- const waits = changes.some((c) => c.kind === "renamed" || c.kind === "removed");
1182
- return `Tier ${tier}: token change. From Figma: design-approved, ${waits ? "waits for a developer OK (a token renamed or removed can break product code)" : "ships once the checks pass"}`;
1183
- }
1184
- var figmaPushNextLine = (changes) => changes.some((c) => c.kind === "renamed" || c.kind === "removed") ? "Pushed from Figma, so design-approved. A developer OKs the rename or removal before it merges." : "Pushed from Figma, so design-approved. Bridge merges it once the automatic checks pass; Undo is in History in Bridge Studio.";
1185
- var BUMP_RANK = { patch: 0, minor: 1, major: 2 };
1186
- var LOW = [/\.md$/, /^docs\//, /\.stories\.tsx?$/, /\.docs\.json$/, /\.test\.tsx?$/, /^\.changeset\//];
1187
- var isSummary = (c) => "kind" in c;
1188
- function summarizeTokenChange(c) {
1189
- if (isSummary(c)) return c;
1190
- const path = (c.target ?? c.current).path;
1191
- if (!c.target) return { kind: "removed", path };
1192
- if (!c.current) return { kind: "added", path };
1193
- if (c.current.path !== c.target.path) return { kind: "renamed", path };
1194
- const fields = fieldChanges(c.current, c.target);
1195
- return { kind: "changed", path, descriptionOnly: fields.length > 0 && fields.every((f) => f.field === "description") };
1196
- }
1197
- function tokenBump(changes) {
1198
- let bump;
1199
- for (const c of changes.map(summarizeTokenChange)) {
1200
- const b = c.kind === "removed" || c.kind === "renamed" ? "major" : c.kind === "changed" && c.descriptionOnly ? "patch" : "minor";
1201
- if (!bump || BUMP_RANK[b] > BUMP_RANK[bump]) bump = b;
1202
- }
1203
- return bump;
1204
- }
1205
- function isGuardrail(path, config) {
1206
- const list2 = config ? [...config.governance.guardrails, CONFIG_FILE, ...config.contrast ? [config.contrast.path] : []] : DEFAULT_GOVERNANCE.guardrails;
1207
- return list2.some((g) => g.endsWith("/") ? path.startsWith(g) : path === g) || path.startsWith(".github/") || path.endsWith("/contrast.checks.json") || path === "contrast.checks.json";
1208
- }
1209
- function classifyRisk(input) {
1210
- const reasons = [];
1211
- const tokenChanges = (input.tokenChanges ?? []).map(summarizeTokenChange);
1212
- for (const c of tokenChanges) {
1213
- if (c.kind === "changed" && c.descriptionOnly) reasons.push({ tier: 1, text: `Token description changed: ${c.path}` });
1214
- else reasons.push({ tier: 3, text: `Token ${c.kind}: ${c.path}` });
1215
- }
1216
- const bump = tokenBump(tokenChanges);
1217
- const componentsDir = input.config?.components?.dir;
1218
- for (const f of input.files ?? []) {
1219
- const p = f.path.replace(/^\/+/, "");
1220
- if (p.endsWith(".tokens.json")) {
1221
- if (!input.tokenChanges) reasons.push({ tier: 3, text: `Token file ${f.status ?? "changed"}: ${p}` });
1222
- } else if (isGuardrail(p, input.config)) reasons.push({ tier: 3, text: `Guardrail file changed: ${p}` });
1223
- else if (LOW.some((r) => r.test(p))) reasons.push({ tier: 1, text: `Docs, story or test: ${p}` });
1224
- else if (componentsDir && p.startsWith(`${componentsDir}/`)) reasons.push({ tier: 2, text: `Component source ${f.status ?? "changed"}: ${p}` });
1225
- else reasons.push({ tier: 2, text: `Source file changed: ${p}` });
1226
- }
1227
- reasons.sort((a, b) => b.tier - a.tier);
1228
- const tier = reasons[0]?.tier ?? 1;
1229
- return { tier, label: RISK_LABELS[tier], reasons: reasons.map((r) => r.text), bump };
1230
- }
1231
-
1232
1100
  // ../core/src/layered.ts
1233
1101
  var DTCG_TYPES = /* @__PURE__ */ new Set(["color", "dimension", "fontFamily", "fontWeight", "duration", "cubicBezier", "number", "strokeStyle", "border", "transition", "shadow", "gradient", "typography", "fontStyle", "string", "boolean"]);
1234
1102
  var aliasOf = (v) => isAlias(v) ? aliasTarget(v) : null;
@@ -1596,6 +1464,164 @@ function layeredParityIssues(set) {
1596
1464
  return out;
1597
1465
  }
1598
1466
 
1467
+ // ../core/src/scopes.ts
1468
+ var has = (segments, ...names) => names.some((n) => segments.includes(n));
1469
+ function defaultScopes(path, type, tier) {
1470
+ const s = path.split(".");
1471
+ switch (type) {
1472
+ case "color":
1473
+ if (tier === "primitive" || tier === "brand" && s[0] === "palette") return [];
1474
+ if (has(s, "text")) return ["TEXT_FILL"];
1475
+ if (has(s, "border", "ring", "outline")) return ["STROKE_COLOR"];
1476
+ if (has(s, "bg", "surface", "background")) return ["FRAME_FILL", "SHAPE_FILL"];
1477
+ if (has(s, "shadow")) return ["EFFECT_COLOR"];
1478
+ return ["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"];
1479
+ case "dimension":
1480
+ if (has(s, "radius")) return ["CORNER_RADIUS"];
1481
+ if (has(s, "border", "width", "stroke")) return ["STROKE_FLOAT"];
1482
+ if (has(s, "space", "spacing", "gap")) return ["GAP", "WIDTH_HEIGHT"];
1483
+ if (has(s, "size") && has(s, "font")) return ["FONT_SIZE"];
1484
+ if (has(s, "letter-spacing")) return ["LETTER_SPACING"];
1485
+ return ["WIDTH_HEIGHT", "GAP"];
1486
+ case "fontWeight":
1487
+ return ["FONT_WEIGHT"];
1488
+ case "fontFamily":
1489
+ return ["FONT_FAMILY"];
1490
+ case "number":
1491
+ if (has(s, "line-height", "lineHeight")) return ["LINE_HEIGHT"];
1492
+ if (has(s, "opacity")) return ["OPACITY"];
1493
+ return ["WIDTH_HEIGHT", "GAP"];
1494
+ case "string":
1495
+ return ["TEXT_CONTENT"];
1496
+ default:
1497
+ return [];
1498
+ }
1499
+ }
1500
+ var sameScopes = (a, b) => {
1501
+ const sa = [...new Set(a)].sort();
1502
+ const sb = [...new Set(b)].sort();
1503
+ return sa.length === sb.length && sa.every((x, i) => x === sb[i]);
1504
+ };
1505
+ var effectiveScopes = (t) => t.scopes ?? defaultScopes(t.path, t.type, t.tier);
1506
+
1507
+ // ../core/src/diff-engine.ts
1508
+ var normDesc = (d) => (d ?? "").trim();
1509
+ function fieldChanges(a, b) {
1510
+ const out = [];
1511
+ if (a.path !== b.path) out.push({ field: "path", before: a.path, after: b.path });
1512
+ if (a.type !== b.type) out.push({ field: "type", before: a.type, after: b.type });
1513
+ const modes = [.../* @__PURE__ */ new Set([...Object.keys(a.values), ...Object.keys(b.values)])];
1514
+ for (const mode of modes) {
1515
+ if (!valuesEqual(b.type, a.values[mode], b.values[mode])) out.push({ field: "value", mode, before: a.values[mode], after: b.values[mode] });
1516
+ }
1517
+ if (normDesc(a.description) !== normDesc(b.description)) out.push({ field: "description", before: a.description, after: b.description });
1518
+ const sa = effectiveScopes(a);
1519
+ const sb = effectiveScopes(b);
1520
+ if (!sameScopes(sa, sb)) out.push({ field: "scopes", before: sa, after: sb });
1521
+ return out;
1522
+ }
1523
+ function sameToken(a, b) {
1524
+ if (!a || !b) return a === b;
1525
+ return fieldChanges(a, b).length === 0;
1526
+ }
1527
+ function sideChange(from, to) {
1528
+ if (!from && !to) return void 0;
1529
+ if (!from) return { kind: "added", fields: [] };
1530
+ if (!to) return { kind: "removed", fields: [] };
1531
+ const fields = fieldChanges(from, to);
1532
+ if (!fields.length) return void 0;
1533
+ return { kind: fields.some((f) => f.field === "path") ? "renamed" : "changed", fields };
1534
+ }
1535
+ function threeWayDiff(base, figma, head) {
1536
+ const ids = /* @__PURE__ */ new Set([...base?.keys() ?? [], ...figma.keys(), ...head.keys()]);
1537
+ const result = { local: [], remote: [], conflicts: [], unchanged: 0 };
1538
+ for (const id of [...ids].sort()) {
1539
+ const b = base?.get(id);
1540
+ const f = figma.get(id);
1541
+ const h = head.get(id);
1542
+ const local = sideChange(b, f);
1543
+ const remote = sideChange(b, h);
1544
+ if (!local && !remote || f && h && sameToken(f, h) || !f && !h) {
1545
+ result.unchanged++;
1546
+ continue;
1547
+ }
1548
+ const entry = { id, path: (f ?? h ?? b).path, base: b, figma: f, head: h, local, remote, conflict: false };
1549
+ if (local && !remote) {
1550
+ entry.path = (f ?? b).path;
1551
+ result.local.push(entry);
1552
+ } else if (remote && !local) {
1553
+ entry.path = (h ?? b).path;
1554
+ result.remote.push(entry);
1555
+ } else {
1556
+ entry.conflict = true;
1557
+ result.conflicts.push(entry);
1558
+ }
1559
+ }
1560
+ const byPath = (a, b) => a.path.localeCompare(b.path);
1561
+ result.local.sort(byPath);
1562
+ result.remote.sort(byPath);
1563
+ result.conflicts.sort(byPath);
1564
+ return result;
1565
+ }
1566
+
1567
+ // ../core/src/risk.ts
1568
+ var RISK_LABELS = {
1569
+ 1: "Tier 1: low risk, one code owner",
1570
+ 2: "Tier 2: medium risk, design review then design + tech approval",
1571
+ 3: "Tier 3: token change, needs design + tech approval"
1572
+ };
1573
+ function figmaPushTierLabel(tier, changes) {
1574
+ const waits = changes.some((c) => c.kind === "renamed" || c.kind === "removed");
1575
+ return `Tier ${tier}: token change. From Figma: design-approved, ${waits ? "waits for a developer OK (a token renamed or removed can break product code)" : "ships once the checks pass"}`;
1576
+ }
1577
+ var figmaPushNextLine = (changes) => changes.some((c) => c.kind === "renamed" || c.kind === "removed") ? "Pushed from Figma, so design-approved. A developer OKs the rename or removal before it merges." : "Pushed from Figma, so design-approved. Bridge merges it once the automatic checks pass; Undo is in History in Bridge Studio.";
1578
+ var BUMP_RANK = { patch: 0, minor: 1, major: 2 };
1579
+ var LOW = [/\.md$/, /^docs\//, /\.stories\.tsx?$/, /\.docs\.json$/, /\.test\.tsx?$/, /^\.changeset\//];
1580
+ var isSummary = (c) => "kind" in c;
1581
+ function summarizeTokenChange(c) {
1582
+ if (isSummary(c)) return c;
1583
+ const path = (c.target ?? c.current).path;
1584
+ if (!c.target) return { kind: "removed", path };
1585
+ if (!c.current) return { kind: "added", path };
1586
+ if (c.current.path !== c.target.path) return { kind: "renamed", path };
1587
+ const fields = fieldChanges(c.current, c.target);
1588
+ return { kind: "changed", path, descriptionOnly: fields.length > 0 && fields.every((f) => f.field === "description") };
1589
+ }
1590
+ function tokenBump(changes) {
1591
+ let bump;
1592
+ for (const c of changes.map(summarizeTokenChange)) {
1593
+ const b = c.kind === "removed" || c.kind === "renamed" ? "major" : c.kind === "changed" && c.descriptionOnly ? "patch" : "minor";
1594
+ if (!bump || BUMP_RANK[b] > BUMP_RANK[bump]) bump = b;
1595
+ }
1596
+ return bump;
1597
+ }
1598
+ function isGuardrail(path, config) {
1599
+ const list2 = config ? [...config.governance.guardrails, CONFIG_FILE, ...config.contrast ? [config.contrast.path] : []] : DEFAULT_GOVERNANCE.guardrails;
1600
+ return list2.some((g) => g.endsWith("/") ? path.startsWith(g) : path === g) || path.startsWith(".github/") || path.endsWith("/contrast.checks.json") || path === "contrast.checks.json";
1601
+ }
1602
+ function classifyRisk(input) {
1603
+ const reasons = [];
1604
+ const tokenChanges = (input.tokenChanges ?? []).map(summarizeTokenChange);
1605
+ for (const c of tokenChanges) {
1606
+ if (c.kind === "changed" && c.descriptionOnly) reasons.push({ tier: 1, text: `Token description changed: ${c.path}` });
1607
+ else reasons.push({ tier: 3, text: `Token ${c.kind}: ${c.path}` });
1608
+ }
1609
+ const bump = tokenBump(tokenChanges);
1610
+ const componentsDir = input.config?.components?.dir;
1611
+ for (const f of input.files ?? []) {
1612
+ const p = f.path.replace(/^\/+/, "");
1613
+ if (p.endsWith(".tokens.json")) {
1614
+ if (!input.tokenChanges) reasons.push({ tier: 3, text: `Token file ${f.status ?? "changed"}: ${p}` });
1615
+ } else if (isGuardrail(p, input.config)) reasons.push({ tier: 3, text: `Guardrail file changed: ${p}` });
1616
+ else if (LOW.some((r) => r.test(p))) reasons.push({ tier: 1, text: `Docs, story or test: ${p}` });
1617
+ else if (componentsDir && p.startsWith(`${componentsDir}/`)) reasons.push({ tier: 2, text: `Component source ${f.status ?? "changed"}: ${p}` });
1618
+ else reasons.push({ tier: 2, text: `Source file changed: ${p}` });
1619
+ }
1620
+ reasons.sort((a, b) => b.tier - a.tier);
1621
+ const tier = reasons[0]?.tier ?? 1;
1622
+ return { tier, label: RISK_LABELS[tier], reasons: reasons.map((r) => r.text), bump };
1623
+ }
1624
+
1599
1625
  // ../core/src/layered-review.ts
1600
1626
  function checkLayeredPair(set, pair, combo) {
1601
1627
  const base = { fg: pair.foreground, bg: pair.background, min: pair.min, modes: comboKey(combo) };
@@ -1830,6 +1856,23 @@ function hashSpec(spec) {
1830
1856
  return specHash(JSON.stringify(rest));
1831
1857
  }
1832
1858
  var variantKeyOf = (props) => Object.keys(props).sort().map((k) => `${k}=${props[k]}`).join(", ") || "Default";
1859
+ function matchVariant(spec, props) {
1860
+ if (!spec) return void 0;
1861
+ const key = variantKeyOf(props);
1862
+ const exact = spec.variants.find((v) => variantKeyOf(v.props) === key);
1863
+ if (exact) return exact;
1864
+ const asked = Object.entries(props);
1865
+ const fits = spec.variants.filter((v) => Object.keys(v.props).length > asked.length && asked.every(([k, val]) => v.props[k] === val));
1866
+ if (fits.length <= 1) return fits[0];
1867
+ const atDefault = (name, value) => {
1868
+ const axis = spec.axes?.find((a) => a.name === name);
1869
+ if (axis) return value === axis.default;
1870
+ const v = value.toLowerCase();
1871
+ return v === "false" || v === "default";
1872
+ };
1873
+ const best = fits.filter((v) => Object.entries(v.props).every(([k, val]) => k in props || atDefault(k, val)));
1874
+ return best.length === 1 ? best[0] : void 0;
1875
+ }
1833
1876
  function parseComponentSpecs(text) {
1834
1877
  let data;
1835
1878
  try {
@@ -2122,6 +2165,7 @@ export {
2122
2165
  readPreviewComment,
2123
2166
  findPreviewComment,
2124
2167
  resolveBranchPreviews,
2168
+ previewLinksOf,
2125
2169
  previewsSettled,
2126
2170
  expandOwners,
2127
2171
  entryLabel,
@@ -2143,6 +2187,9 @@ export {
2143
2187
  findProduct,
2144
2188
  productOfBranch,
2145
2189
  productForPlace,
2190
+ productRepoOf,
2191
+ productRepos,
2192
+ productOfRepo,
2146
2193
  CONFIG_FILE,
2147
2194
  DEFAULT_OVERRIDE,
2148
2195
  DEFAULT_TOKEN_PATHS,
@@ -2188,6 +2235,7 @@ export {
2188
2235
  coverageSummary,
2189
2236
  hashSpec,
2190
2237
  variantKeyOf,
2238
+ matchVariant,
2191
2239
  parseComponentSpecs,
2192
2240
  figmaCell,
2193
2241
  FIGMA_REFERENCES_BRANCH,
package/dist/context.d.ts CHANGED
@@ -23,6 +23,13 @@ export interface ContextOptions {
23
23
  surface?: 'mcp' | 'cli';
24
24
  /** How a person adds a token where Bridge runs, for the rate-limit message. Default: the MCP server's environment. */
25
25
  tokenHint?: string;
26
+ /**
27
+ * Remote mode run inside a connected product repository (`.bridge/product.json`, product-link.ts): that checkout,
28
+ * which continuing one of its explorations switches to the branch when safe.
29
+ */
30
+ workTree?: string;
31
+ /** The product key of that repository: new work and a bare reference (#12) default to it. */
32
+ product?: string;
26
33
  }
27
34
  /** Whether a request only reads: GET and HEAD, and GraphQL queries (POST /graphql without a mutation). */
28
35
  export declare function isReadRequest(url: string, init?: {
@@ -52,6 +59,18 @@ export declare class BridgeContext {
52
59
  * branch are refused, new branches need the agent prefix. Local mode uses config.repo or the origin remote.
53
60
  */
54
61
  github(): Promise<GitHubClient | null>;
62
+ /**
63
+ * A client for another repository (a product's own one) with the same token, fetch and hosts. Its default branch
64
+ * and the protected ones are refused, new branches need `branchPrefix`.
65
+ */
66
+ githubFor(repo: string, rules: {
67
+ protectedBranches: string[];
68
+ branchPrefix: string;
69
+ }): GitHubClient;
70
+ /** The local checkout Bridge may switch branches in: the repo root in local mode, the connected product checkout in remote mode. */
71
+ get checkout(): string | undefined;
72
+ /** The product of the connected product repository Bridge runs in (`.bridge/product.json`), if any. */
73
+ get product(): string | undefined;
55
74
  get canWriteRemote(): boolean;
56
75
  /** Who reads the messages: 'mcp' (tool names and arguments, the default) or 'cli' (commands and flags). */
57
76
  get surface(): 'mcp' | 'cli';