@bridge-ds/cli 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.
Files changed (2) hide show
  1. package/dist/index.js +689 -136
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1115,9 +1115,9 @@ var DesignSystemIndex = class {
1115
1115
  return generic ? this.make(generic, 1, 0.3) : void 0;
1116
1116
  }
1117
1117
  case "shadow": {
1118
- const norm3 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
1118
+ const norm4 = (s) => s.replace(/\s+/g, " ").replace(/\s*,\s*/g, ", ").trim().toLowerCase();
1119
1119
  const cands = this.entries(["shadow"]);
1120
- const exact = cands.find((e) => norm3(e.css) === norm3(value));
1120
+ const exact = cands.find((e) => norm4(e.css) === norm4(value));
1121
1121
  if (exact) return this.make(exact, 0, 1);
1122
1122
  const t = shadowNums(value);
1123
1123
  const scored = cands.map((e) => {
@@ -1766,6 +1766,226 @@ async function originRepo(root) {
1766
1766
  // ../core/src/previews.ts
1767
1767
  var PREVIEW_APP_LABELS = { docs: "Docs", storybook: "Storybook", admin: "Admin demo" };
1768
1768
 
1769
+ // ../core/src/teams.ts
1770
+ var LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
1771
+ var SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1772
+ function parseOwnerEntry(raw) {
1773
+ if (typeof raw !== "string") return null;
1774
+ const s = raw.trim().replace(/^@/, "");
1775
+ const slash = s.indexOf("/");
1776
+ if (slash < 0) return LOGIN.test(s) ? { kind: "person", login: s } : null;
1777
+ const org = s.slice(0, slash);
1778
+ const slug = s.slice(slash + 1);
1779
+ return LOGIN.test(org) && SLUG.test(slug) ? { kind: "team", org, slug } : null;
1780
+ }
1781
+ var teamKey = (org, slug) => `@${org.toLowerCase()}/${slug.toLowerCase()}`;
1782
+ function ownerEntryProblems(raw) {
1783
+ if (raw === void 0) return [];
1784
+ const isObj2 = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
1785
+ if (!isObj2(raw)) return ['"governance.owners" must be { "design": [people or teams], "tech": [people or teams] }'];
1786
+ const out = [];
1787
+ for (const [k, v] of Object.entries(raw)) {
1788
+ if (k !== "design" && k !== "tech") {
1789
+ out.push(`"governance.owners.${k}" is not an owner group (design, tech)`);
1790
+ continue;
1791
+ }
1792
+ if (!Array.isArray(v)) {
1793
+ out.push(`"governance.owners.${k}" must be a list of people or teams`);
1794
+ continue;
1795
+ }
1796
+ v.forEach((x, i) => {
1797
+ if (typeof x !== "string" || !parseOwnerEntry(x))
1798
+ 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".`);
1799
+ });
1800
+ }
1801
+ return out;
1802
+ }
1803
+ function teamUnreadableMessage(team, reason) {
1804
+ const name = `@${team.org}/${team.slug}`;
1805
+ switch (reason) {
1806
+ case "personal-account":
1807
+ 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.`;
1808
+ case "missing-permission":
1809
+ 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).`;
1810
+ case "no-token":
1811
+ return `The members of ${name} cannot be read without a working GitHub token. Connect GitHub with a token that has read:org.`;
1812
+ case "not-found":
1813
+ return `${name} was not found. Check the team slug on GitHub, or give the token read:org so it can see the team.`;
1814
+ case "unreachable":
1815
+ return `The members of ${name} cannot be read right now: GitHub did not answer. Try again in a moment.`;
1816
+ }
1817
+ }
1818
+ function teamsIn(owners) {
1819
+ const all = Array.isArray(owners) ? owners : [...owners.design ?? [], ...owners.tech ?? []];
1820
+ const out = /* @__PURE__ */ new Map();
1821
+ for (const raw of all) {
1822
+ const e = parseOwnerEntry(raw);
1823
+ if (e?.kind === "team") out.set(teamKey(e.org, e.slug), { key: teamKey(e.org, e.slug), org: e.org, slug: e.slug });
1824
+ }
1825
+ return [...out.values()];
1826
+ }
1827
+ var norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
1828
+ var people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
1829
+ function entryLabel(raw, teams) {
1830
+ const e = parseOwnerEntry(raw);
1831
+ if (!e) return raw;
1832
+ if (e.kind === "person") return `@${e.login}`;
1833
+ const state = teams?.[teamKey(e.org, e.slug)];
1834
+ const name = `@${e.org}/${e.slug}`;
1835
+ return state?.status === "ok" ? `${name} (${people(state.members.length)})` : name;
1836
+ }
1837
+ var TeamDirectory = class {
1838
+ constructor(reader, opts = {}) {
1839
+ this.reader = reader;
1840
+ this.ttl = opts.ttlMs ?? 5 * 6e4;
1841
+ this.now = opts.now ?? Date.now;
1842
+ }
1843
+ reader;
1844
+ teams = /* @__PURE__ */ new Map();
1845
+ accounts = /* @__PURE__ */ new Map();
1846
+ memberships = /* @__PURE__ */ new Map();
1847
+ ttl;
1848
+ now;
1849
+ accountType(org) {
1850
+ const k = org.toLowerCase();
1851
+ let p = this.accounts.get(k);
1852
+ if (!p) {
1853
+ p = this.reader.getAccountType(org).catch(() => null);
1854
+ this.accounts.set(k, p);
1855
+ }
1856
+ return p;
1857
+ }
1858
+ /** The reason a read of `org` failed, as a state. A 404 on a personal account says so; a 403 is a missing permission. */
1859
+ async failure(ref, err) {
1860
+ const e = err ?? {};
1861
+ let reason;
1862
+ if (!this.reader.hasToken) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "no-token";
1863
+ else if (e.status === 401) reason = "no-token";
1864
+ else if (e.status === 403 || e.auth === "missing-permission") reason = "missing-permission";
1865
+ else if (e.status === 404) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "not-found";
1866
+ else reason = "unreachable";
1867
+ return { ...ref, status: "unreadable", reason, message: teamUnreadableMessage(ref, reason) };
1868
+ }
1869
+ /** Members of one team, or why they cannot be read. Cached. */
1870
+ team(org, slug) {
1871
+ const ref = { key: teamKey(org, slug), org, slug };
1872
+ const hit = this.teams.get(ref.key);
1873
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
1874
+ const value = (async () => {
1875
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
1876
+ try {
1877
+ const members = await this.reader.listTeamMembers(org, slug);
1878
+ return { ...ref, status: "ok", members };
1879
+ } catch (err) {
1880
+ return this.failure(ref, err);
1881
+ }
1882
+ })();
1883
+ this.teams.set(ref.key, { at: this.now(), value });
1884
+ void value.then((s) => {
1885
+ if (s.status === "unreadable" && s.reason === "unreachable") this.teams.delete(ref.key);
1886
+ });
1887
+ return value;
1888
+ }
1889
+ /** Every team named in the owner lists, read in parallel. Empty object when no team is named (no request). */
1890
+ async resolve(owners) {
1891
+ const refs = teamsIn(owners);
1892
+ const states = await Promise.all(refs.map((r) => this.team(r.org, r.slug)));
1893
+ return Object.fromEntries(states.map((s) => [s.key, s]));
1894
+ }
1895
+ /**
1896
+ * Whether `login` is an active member of one team: from the cached member list when there is one, else one
1897
+ * membership read (GET /orgs/{org}/teams/{slug}/memberships/{user}). An unreadable team returns its state.
1898
+ */
1899
+ async isMember(org, slug, login) {
1900
+ const ref = { key: teamKey(org, slug), org, slug };
1901
+ const cached = this.teams.get(ref.key);
1902
+ if (cached && this.now() - cached.at < this.ttl) {
1903
+ const s = await cached.value;
1904
+ if (s.status === "ok") return s.members.some((m) => norm(m) === norm(login));
1905
+ }
1906
+ const k = `${ref.key}#${norm(login)}`;
1907
+ const hit = this.memberships.get(k);
1908
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
1909
+ const value = (async () => {
1910
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
1911
+ try {
1912
+ return await this.reader.getTeamMembership(org, slug, login) === "active";
1913
+ } catch (err) {
1914
+ return this.failure(ref, err);
1915
+ }
1916
+ })();
1917
+ this.memberships.set(k, { at: this.now(), value });
1918
+ return value;
1919
+ }
1920
+ /**
1921
+ * Whether `login` holds a right given to `entries` (people and teams): a person entry matches directly, a team
1922
+ * through its membership. Teams are only read when no person entry matches.
1923
+ */
1924
+ async memberOfAny(entries, login) {
1925
+ const unreadable = [];
1926
+ const parsed = entries.map(parseOwnerEntry).filter((e) => Boolean(e));
1927
+ if (parsed.some((e) => e.kind === "person" && norm(e.login) === norm(login))) return { member: true, unreadable };
1928
+ for (const e of parsed) {
1929
+ if (e.kind !== "team") continue;
1930
+ const r = await this.isMember(e.org, e.slug, login);
1931
+ if (r === true) return { member: true, unreadable };
1932
+ if (typeof r === "object" && r.status === "unreadable") unreadable.push(r);
1933
+ }
1934
+ return { member: false, unreadable };
1935
+ }
1936
+ /** The organization's teams for a picker, or why they cannot be listed (a personal account has none). */
1937
+ async orgTeams(org) {
1938
+ if (await this.accountType(org) === "User") return { status: "unreadable", reason: "personal-account" };
1939
+ if (!this.reader.hasToken) return { status: "unreadable", reason: "no-token" };
1940
+ try {
1941
+ return { status: "ok", teams: await this.reader.listOrgTeams(org) };
1942
+ } catch (err) {
1943
+ const s = await this.failure({ key: teamKey(org, "*"), org, slug: "*" }, err);
1944
+ return { status: "unreadable", reason: s.status === "unreadable" ? s.reason : "unreachable" };
1945
+ }
1946
+ }
1947
+ /** Drops every cached read (after the token changes). */
1948
+ clear() {
1949
+ this.teams.clear();
1950
+ this.accounts.clear();
1951
+ this.memberships.clear();
1952
+ }
1953
+ };
1954
+
1955
+ // ../core/src/rights.ts
1956
+ var OWNER_GROUPS = ["design", "tech"];
1957
+ var RIGHTS = ["approveDesign", "approveTech", "ship"];
1958
+ var DEFAULT_RIGHTS = { approveDesign: ["design"], approveTech: ["tech"], ship: ["tech"] };
1959
+ function normalizeRights(input) {
1960
+ const pick = (r) => {
1961
+ const v = input?.[r];
1962
+ if (!Array.isArray(v)) return [...DEFAULT_RIGHTS[r]];
1963
+ return OWNER_GROUPS.filter((g) => v.includes(g));
1964
+ };
1965
+ return { approveDesign: pick("approveDesign"), approveTech: pick("approveTech"), ship: pick("ship") };
1966
+ }
1967
+ function rightsProblems(raw) {
1968
+ if (raw === void 0) return [];
1969
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"governance.rights" must be { "approveDesign": [groups], "approveTech": [groups], "ship": [groups] }'];
1970
+ const out = [];
1971
+ for (const [k, v] of Object.entries(raw)) {
1972
+ if (!RIGHTS.includes(k)) out.push(`"governance.rights.${k}" is not a right (${RIGHTS.join(", ")})`);
1973
+ else if (!Array.isArray(v) || v.length === 0 || !v.every((g) => OWNER_GROUPS.includes(g)))
1974
+ out.push(`"governance.rights.${k}" must list one or more of "design", "tech"`);
1975
+ }
1976
+ return out;
1977
+ }
1978
+ function visualImpactProblems(raw) {
1979
+ if (raw === void 0) return [];
1980
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"visualImpact" must be { "mode": "auto" | "on-demand" }'];
1981
+ const out = [];
1982
+ for (const [k, v] of Object.entries(raw)) {
1983
+ if (k !== "mode") out.push(`"visualImpact.${k}" is not a setting (mode)`);
1984
+ else if (v !== "auto" && v !== "on-demand") out.push('"visualImpact.mode" must be "auto" or "on-demand"');
1985
+ }
1986
+ return out;
1987
+ }
1988
+
1769
1989
  // ../core/src/config.ts
1770
1990
  var CONFIG_FILE = "bridge.config.json";
1771
1991
  var DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
@@ -1903,9 +2123,11 @@ function defineConfig(input) {
1903
2123
  requireReason: g.override.requireReason ?? DEFAULT_OVERRIDE.requireReason,
1904
2124
  minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
1905
2125
  }
1906
- } : {}
2126
+ } : {},
2127
+ ...g.rights ? { rights: normalizeRights(g.rights) } : {}
1907
2128
  },
1908
2129
  ...input.audit ? { audit: { ...input.audit } } : {},
2130
+ ...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
1909
2131
  ...input.integrations ? { integrations: { ...input.integrations } } : {},
1910
2132
  ...input.previews ? {
1911
2133
  previews: {
@@ -2012,9 +2234,7 @@ function loadConfig(source) {
2012
2234
  else {
2013
2235
  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`);
2014
2236
  if (gov.branchPrefix !== void 0 && !isStr(gov.branchPrefix)) problems.push('"governance.branchPrefix" must be a non-empty string');
2015
- const o = gov.owners;
2016
- 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")))))
2017
- problems.push('"governance.owners" must be { "design": [logins], "tech": [logins] }');
2237
+ problems.push(...ownerEntryProblems(gov.owners));
2018
2238
  const ov = gov.override;
2019
2239
  if (ov !== void 0) {
2020
2240
  if (!isObj(ov)) problems.push('"governance.override" must be an object');
@@ -2024,8 +2244,10 @@ function loadConfig(source) {
2024
2244
  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');
2025
2245
  }
2026
2246
  }
2247
+ problems.push(...rightsProblems(gov.rights));
2027
2248
  }
2028
2249
  }
2250
+ problems.push(...visualImpactProblems(d.visualImpact));
2029
2251
  const audit2 = d.audit;
2030
2252
  if (audit2 !== void 0) {
2031
2253
  if (!isObj(audit2)) problems.push('"audit" must be an object');
@@ -2237,125 +2459,147 @@ import { mkdir as mkdir3, readFile as readFile3, rm as rm2, unlink, writeFile as
2237
2459
  import { tmpdir } from "node:os";
2238
2460
  import { join as join4, relative as relative3, resolve as resolve2 } from "node:path";
2239
2461
 
2240
- // ../core/src/usage.ts
2241
- var VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
2242
- var IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
2243
- function cssVarsIn(text) {
2244
- const out = /* @__PURE__ */ new Set();
2245
- for (const m of text.matchAll(VAR_RE)) out.add(m[1]);
2246
- return [...out];
2247
- }
2248
- function importsIn(text) {
2249
- const out = /* @__PURE__ */ new Set();
2250
- for (const m of text.matchAll(IMPORT_RE)) out.add(m[1] ?? m[2]);
2251
- return [...out];
2252
- }
2253
- var norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
2254
- var isStory = (p) => /\.stories\.[cm]?[jt]sx?$/.test(p);
2255
- var isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
2256
- var isCss = (p) => /\.(css|scss|less)$/.test(p);
2257
- var isScript = (p) => /\.[cm]?[jt]sx?$/.test(p);
2258
- function componentOf(from, spec, componentsDir) {
2259
- if (!spec.startsWith(".")) return null;
2260
- const parts = from.split("/").slice(0, -1);
2261
- for (const seg of spec.split("/")) {
2262
- if (seg === "." || seg === "") continue;
2263
- if (seg === "..") parts.pop();
2264
- else parts.push(seg);
2265
- }
2266
- const abs = parts.join("/");
2267
- if (!abs.startsWith(`${componentsDir}/`)) return null;
2268
- return abs.slice(componentsDir.length + 1).split("/")[0] || null;
2269
- }
2270
- function componentForPath(path, componentsDir) {
2271
- const dir = norm(componentsDir);
2272
- const p = norm(path);
2273
- if (!p.startsWith(`${dir}/`)) return null;
2274
- const rest = p.slice(dir.length + 1).split("/");
2275
- return rest.length >= 2 ? rest[0] : null;
2276
- }
2277
- function buildUsageIndex(files, componentsDir) {
2278
- const dir = norm(componentsDir);
2279
- const modules = {};
2280
- const vars = /* @__PURE__ */ new Map();
2281
- const imports = /* @__PURE__ */ new Map();
2282
- const storyImports = /* @__PURE__ */ new Map();
2283
- const globalVars = {};
2284
- const get = (m, k) => m.get(k) ?? m.set(k, /* @__PURE__ */ new Set()).get(k);
2285
- for (const f of files) {
2286
- const path = norm(f.path);
2287
- const id = componentForPath(path, dir);
2288
- if (!id) {
2289
- if (isCss(path)) {
2290
- const v = cssVarsIn(f.text);
2291
- if (v.length) globalVars[path] = v.sort();
2292
- }
2293
- continue;
2294
- }
2295
- const mod = modules[id] ??= { id, dir: `${dir}/${id}`, files: [], vars: [], imports: [], storyImports: [] };
2296
- mod.files.push(path);
2297
- if (isTest(path)) continue;
2298
- for (const v of cssVarsIn(f.text)) get(vars, id).add(v);
2299
- if (!isScript(path)) continue;
2300
- for (const spec of importsIn(f.text)) {
2301
- const dep = componentOf(path, spec, dir);
2302
- if (!dep || dep === id) continue;
2303
- get(isStory(path) ? storyImports : imports, id).add(dep);
2304
- }
2305
- }
2306
- const byVar = {};
2307
- for (const mod of Object.values(modules)) {
2308
- mod.files.sort();
2309
- mod.vars = [...vars.get(mod.id) ?? []].sort();
2310
- mod.imports = [...imports.get(mod.id) ?? []].sort();
2311
- mod.storyImports = [...storyImports.get(mod.id) ?? []].filter((d) => !mod.imports.includes(d)).sort();
2312
- for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
2313
- }
2314
- for (const k of Object.keys(byVar)) byVar[k].sort();
2315
- return { componentsDir: dir, modules, byVar, globalVars };
2316
- }
2317
- function impactedModules(index, input) {
2318
- const reasons = /* @__PURE__ */ new Map();
2319
- const add = (id, reason, indirect) => {
2320
- const r = reasons.get(id);
2321
- if (r) {
2322
- if (!r.reasons.includes(reason)) r.reasons.push(reason);
2323
- r.indirect &&= indirect;
2324
- } else reasons.set(id, { reasons: [reason], indirect });
2325
- };
2326
- const changedVars = new Set(input.vars ?? []);
2327
- for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
2328
- for (const f of input.files ?? []) {
2329
- const id = componentForPath(f, index.componentsDir);
2330
- if (id && index.modules[id]) add(id, `file changed: ${norm(f)}`, false);
2331
- }
2332
- const renderedBy = /* @__PURE__ */ new Map();
2333
- for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
2334
- const queue = [...reasons.keys()];
2335
- while (queue.length) {
2336
- const id = queue.shift();
2337
- for (const parent of renderedBy.get(id) ?? []) {
2338
- const known = reasons.has(parent);
2339
- add(parent, `renders ${id}`, true);
2340
- if (!known) queue.push(parent);
2462
+ // ../core/src/protection.ts
2463
+ var PROTECTION_PARTS = ["review", "checks", "history", "deletion"];
2464
+ var protectionRulesetName = (defaultBranch) => `Protect the library (${defaultBranch})`;
2465
+ function refPattern(pattern) {
2466
+ const src = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
2467
+ return new RegExp(`^${src}$`);
2468
+ }
2469
+ function rulesetTargetsDefault(ruleset, defaultBranch) {
2470
+ if (ruleset.target && ruleset.target !== "branch") return false;
2471
+ const ref = `refs/heads/${defaultBranch}`;
2472
+ const hit = (p) => p === "~ALL" || p === "~DEFAULT_BRANCH" || p === ref || p === defaultBranch || refPattern(p.startsWith("refs/") ? p : `refs/heads/${p}`).test(ref);
2473
+ const cond = ruleset.conditions?.ref_name;
2474
+ if (!cond) return false;
2475
+ if ((cond.exclude ?? []).some(hit)) return false;
2476
+ return (cond.include ?? []).some(hit);
2477
+ }
2478
+ var count = (v) => typeof v === "number" ? v : 0;
2479
+ function rulesetParts(ruleset) {
2480
+ const parts = /* @__PURE__ */ new Set();
2481
+ if (ruleset.enforcement !== "active") return parts;
2482
+ for (const rule of ruleset.rules ?? []) {
2483
+ if (rule.type === "deletion") parts.add("deletion");
2484
+ if (rule.type === "non_fast_forward") parts.add("history");
2485
+ if (rule.type === "pull_request" && count(rule.parameters?.required_approving_review_count) >= 1) parts.add("review");
2486
+ if (rule.type === "required_status_checks" && Array.isArray(rule.parameters?.required_status_checks) && rule.parameters.required_status_checks.length > 0) parts.add("checks");
2487
+ }
2488
+ return parts;
2489
+ }
2490
+ function classicParts(p) {
2491
+ const parts = /* @__PURE__ */ new Set();
2492
+ if (count(p.required_pull_request_reviews?.required_approving_review_count) >= 1) parts.add("review");
2493
+ if (((p.required_status_checks?.contexts?.length ?? 0) || (p.required_status_checks?.checks?.length ?? 0)) > 0) parts.add("checks");
2494
+ if (!p.allow_force_pushes?.enabled) parts.add("history");
2495
+ if (!p.allow_deletions?.enabled) parts.add("deletion");
2496
+ return parts;
2497
+ }
2498
+ var rulesetUrl = (r) => r.html_url ?? r._links?.html?.href;
2499
+ function protectionState(rulesets, branchProtection, defaultBranch) {
2500
+ const bridge = rulesets?.find((r) => r.name === protectionRulesetName(defaultBranch));
2501
+ const extra = bridge?.id !== void 0 ? { bridgeRulesetId: bridge.id } : {};
2502
+ const covered = /* @__PURE__ */ new Set();
2503
+ let url;
2504
+ for (const r of rulesets ?? []) {
2505
+ if (!rulesetTargetsDefault(r, defaultBranch)) continue;
2506
+ const parts = rulesetParts(r);
2507
+ if (parts.size && !url) url = rulesetUrl(r);
2508
+ parts.forEach((p) => covered.add(p));
2509
+ }
2510
+ if (branchProtection) classicParts(branchProtection).forEach((p) => covered.add(p));
2511
+ const missing = PROTECTION_PARTS.filter((p) => !covered.has(p));
2512
+ const withUrl = url ? { url } : {};
2513
+ if (!missing.length) return { kind: "protected", missing, ...extra, ...withUrl };
2514
+ const unreadable = rulesets == null || branchProtection === void 0;
2515
+ if (covered.size === 0) return { kind: unreadable ? "unknown" : "unprotected", missing, ...extra };
2516
+ return { kind: "partial", missing, ...extra, ...withUrl };
2517
+ }
2518
+ var PROTECTION_PART_PHRASE = {
2519
+ review: "changes can skip review",
2520
+ checks: "checks do not have to pass",
2521
+ history: "past changes can be rewritten",
2522
+ deletion: "the library can be deleted"
2523
+ };
2524
+ var joinAnd = (xs) => xs.length <= 1 ? xs[0] ?? "" : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`;
2525
+ function protectionSentence(state) {
2526
+ switch (state.kind) {
2527
+ case "protected":
2528
+ return "Protected: every change goes through review.";
2529
+ case "unprotected":
2530
+ return "Your library can be changed without review.";
2531
+ case "partial": {
2532
+ const s = joinAnd(state.missing.map((p) => PROTECTION_PART_PHRASE[p]));
2533
+ return `Partly protected: ${s}.`;
2341
2534
  }
2535
+ default:
2536
+ return "Protection status unknown: GitHub did not let this token read it.";
2342
2537
  }
2343
- const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
2344
- const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
2345
- return { modules, globalHits };
2346
2538
  }
2347
2539
 
2348
2540
  // ../core/src/github-client.ts
2349
2541
  var GitHubError = class extends Error {
2350
- constructor(status, message, detail) {
2542
+ constructor(status, message, detail, info = {}) {
2351
2543
  super(message);
2352
2544
  this.status = status;
2353
2545
  this.detail = detail;
2354
2546
  this.name = "GitHubError";
2547
+ this.auth = info.auth;
2548
+ this.permission = info.permission;
2355
2549
  }
2356
2550
  status;
2357
2551
  detail;
2552
+ /** Set when the failure is about the token: rejected (401) or missing a permission (403). */
2553
+ auth;
2554
+ /** With auth "missing-permission": the permission the call needed, when known. */
2555
+ permission;
2358
2556
  };
2557
+ var PERMISSION_LABELS = {
2558
+ issues: "Issues",
2559
+ pull_requests: "Pull requests",
2560
+ contents: "Contents",
2561
+ checks: "Checks",
2562
+ statuses: "Commit statuses",
2563
+ metadata: "Metadata",
2564
+ actions: "Actions",
2565
+ deployments: "Deployments",
2566
+ workflows: "Workflows",
2567
+ administration: "Administration"
2568
+ };
2569
+ function permissionLabel(permission) {
2570
+ return PERMISSION_LABELS[permission] ?? permission.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
2571
+ }
2572
+ var TOKEN_REJECTED_MESSAGE = "Your GitHub token stopped working. It was revoked, regenerated or has expired: replace it with a new one.";
2573
+ function missingPermissionMessage(permission, action) {
2574
+ const what = action ? `This token cannot ${action}.` : "This token is missing a permission for this.";
2575
+ if (!permission) return `${what} Check its repository permissions on GitHub (Contents and Pull requests need Read and write).`;
2576
+ return `${what} Add ${permissionLabel(permission)}: Read and write on GitHub.`;
2577
+ }
2578
+ function requiredPermission(path, acceptedHeader) {
2579
+ if (acceptedHeader) {
2580
+ const first = acceptedHeader.split(";")[0].split(",").map((p2) => p2.trim().split("=")[0].trim()).filter((p2) => p2 && p2 !== "metadata");
2581
+ if (first.length) return first[0];
2582
+ }
2583
+ const p = path.replace(/[?#].*$/, "");
2584
+ if (/\/repos\/[^/]+\/[^/]+\/(issues|labels|milestones)(\/|$)/.test(p)) return "issues";
2585
+ if (/\/repos\/[^/]+\/[^/]+\/pulls(\/|$)/.test(p)) return "pull_requests";
2586
+ if (/\/repos\/[^/]+\/[^/]+\/(rulesets(\/|$)|branches\/.+\/protection$)/.test(p)) return "administration";
2587
+ if (/\/repos\/[^/]+\/[^/]+\/(contents|git|commits|branches|merges)(\/|$)/.test(p)) return "contents";
2588
+ if (/\/repos\/[^/]+\/[^/]+\/(check-runs|check-suites)(\/|$)/.test(p)) return "checks";
2589
+ if (/\/repos\/[^/]+\/[^/]+\/statuses(\/|$)/.test(p)) return "statuses";
2590
+ if (/\/repos\/[^/]+\/[^/]+\/deployments(\/|$)/.test(p)) return "deployments";
2591
+ if (/\/repos\/[^/]+\/[^/]+\/actions(\/|$)/.test(p)) return "actions";
2592
+ return void 0;
2593
+ }
2594
+ var PERMISSION_DENIED = /not accessible by (personal access token|integration)|must have (admin|push|write) (rights|access)|write access to repository not granted|permission to .* denied/i;
2595
+ function classifyAuth(status, detail, opts = {}) {
2596
+ if (status === 401 && opts.withToken !== false) return { auth: "rejected" };
2597
+ if (status !== 403 || /rate limit|archived/i.test(detail)) return {};
2598
+ if (opts.acceptedPermissions || PERMISSION_DENIED.test(detail)) {
2599
+ return { auth: "missing-permission", permission: requiredPermission(opts.path ?? "", opts.acceptedPermissions) };
2600
+ }
2601
+ return {};
2602
+ }
2359
2603
  var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 600, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
2360
2604
  var isTransient = (status, detail) => status === 0 || status >= 500 || status === 429 || status === 403 && /secondary rate limit/i.test(detail);
2361
2605
  var API = "https://api.github.com";
@@ -2417,7 +2661,7 @@ var GitHubClient = class _GitHubClient {
2417
2661
  }
2418
2662
  }
2419
2663
  assertToken(what) {
2420
- if (!this.token) throw new GitHubError(401, `${what} needs a GitHub token (contents and pull requests, read and write, on this repository only). Without one Bridge is read-only.`);
2664
+ if (!this.token) throw new GitHubError(401, `${what} needs a GitHub token (contents and pull requests, read and write, on this repository only). Without one Bridge is read-only.`, "no-token");
2421
2665
  }
2422
2666
  get repoPath() {
2423
2667
  return `/repos/${this.owner}/${this.name}`;
@@ -2466,7 +2710,12 @@ var GitHubClient = class _GitHubClient {
2466
2710
  detail
2467
2711
  );
2468
2712
  }
2469
- throw new GitHubError(res.status, friendlyMessage(res.status, detail), detail);
2713
+ const info = classifyAuth(res.status, detail, {
2714
+ path,
2715
+ acceptedPermissions: res.headers?.get("x-accepted-github-permissions"),
2716
+ withToken: Boolean(this.token)
2717
+ });
2718
+ throw new GitHubError(res.status, friendlyMessage(res.status, detail, info), detail, info);
2470
2719
  }
2471
2720
  return await res.json();
2472
2721
  }
@@ -2645,6 +2894,11 @@ var GitHubClient = class _GitHubClient {
2645
2894
  async addComment(issueNumber2, body) {
2646
2895
  await this.request("POST", `${this.repoPath}/issues/${issueNumber2}/comments`, { body });
2647
2896
  }
2897
+ /** Comments on an issue or pull request, oldest first (first 100: the sticky preview comment is posted early). */
2898
+ async listIssueComments(issueNumber2) {
2899
+ const r = await this.request("GET", `${this.repoPath}/issues/${issueNumber2}/comments?per_page=100`);
2900
+ return r.map((c) => ({ id: c.id, body: c.body ?? "", author: c.user?.login ?? "unknown", updatedAt: c.updated_at }));
2901
+ }
2648
2902
  // --- Issues (component change requests) ---------------------------------------------------
2649
2903
  async createIssue(input) {
2650
2904
  const r = await this.request("POST", `${this.repoPath}/issues`, {
@@ -2665,6 +2919,67 @@ var GitHubClient = class _GitHubClient {
2665
2919
  async getIssue(n) {
2666
2920
  return toIssue(await this.request("GET", `${this.repoPath}/issues/${n}`));
2667
2921
  }
2922
+ /** Edits an issue: its body, or closes it with a reason ("not_planned" for a declined request). */
2923
+ async updateIssue(n, patch) {
2924
+ this.assertToken("Editing an issue");
2925
+ return toIssue(await this.request("PATCH", `${this.repoPath}/issues/${n}`, patch));
2926
+ }
2927
+ /** Adds people to an issue's assignees (GitHub ignores logins that cannot be assigned: the result says who is). */
2928
+ async addAssignees(n, logins) {
2929
+ this.assertToken("Assigning an issue");
2930
+ const r = await this.request("POST", `${this.repoPath}/issues/${n}/assignees`, { assignees: logins });
2931
+ return (r.assignees ?? []).map((a) => a.login);
2932
+ }
2933
+ /** People with at least `permission` on the repository (reading the list needs push access). First 100. */
2934
+ async listCollaborators(permission = "push") {
2935
+ const r = await this.request("GET", `${this.repoPath}/collaborators?permission=${permission}&per_page=100`);
2936
+ return r.map((c) => ({ login: c.login, avatarUrl: c.avatar_url ?? "" }));
2937
+ }
2938
+ /** Whether a token is sent with each request (TeamReader). */
2939
+ get hasToken() {
2940
+ return Boolean(this.token);
2941
+ }
2942
+ /** Every page of a list endpoint (per_page 100), up to `maxPages`. */
2943
+ async paginate(path, maxPages = 20) {
2944
+ const out = [];
2945
+ const sep3 = path.includes("?") ? "&" : "?";
2946
+ for (let page = 1; page <= maxPages; page++) {
2947
+ const r = await this.request("GET", `${path}${sep3}per_page=100&page=${page}`);
2948
+ out.push(...r);
2949
+ if (r.length < 100) break;
2950
+ }
2951
+ return out;
2952
+ }
2953
+ /** Every member login of an organization team (needs read:org, or Members: Read on a fine-grained token). */
2954
+ async listTeamMembers(org, slug) {
2955
+ const r = await this.paginate(`/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(slug)}/members`);
2956
+ return r.map((m) => m.login);
2957
+ }
2958
+ /** A user's membership of a team: "active", "pending", or null when GitHub says they are not a member (404). */
2959
+ async getTeamMembership(org, slug, login) {
2960
+ try {
2961
+ const r = await this.request("GET", `/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(slug)}/memberships/${encodeURIComponent(login)}`);
2962
+ return r.state === "active" ? "active" : "pending";
2963
+ } catch (err) {
2964
+ if (err instanceof GitHubError && err.status === 404) return null;
2965
+ throw err;
2966
+ }
2967
+ }
2968
+ /** "User" or "Organization" for a login, null when GitHub does not know it. */
2969
+ async getAccountType(login) {
2970
+ try {
2971
+ const r = await this.request("GET", `/users/${encodeURIComponent(login)}`);
2972
+ return r.type === "Organization" ? "Organization" : r.type === "User" ? "User" : null;
2973
+ } catch (err) {
2974
+ if (err instanceof GitHubError && err.status === 404) return null;
2975
+ throw err;
2976
+ }
2977
+ }
2978
+ /** The organization's teams this token can see. */
2979
+ async listOrgTeams(org) {
2980
+ const r = await this.paginate(`/orgs/${encodeURIComponent(org)}/teams`);
2981
+ return r.map((t) => ({ slug: t.slug, name: t.name }));
2982
+ }
2668
2983
  /** Create or update a binary file (base64 content) on a branch, e.g. a Figma export attached to a request. */
2669
2984
  async putBase64File(input) {
2670
2985
  this.assertWritableBranch(input.branch);
@@ -2800,6 +3115,27 @@ var GitHubClient = class _GitHubClient {
2800
3115
  const pending = checks.some((c) => c.status !== "completed");
2801
3116
  return { state: !checks.length ? "none" : failing ? "failure" : pending ? "pending" : "success", checks };
2802
3117
  }
3118
+ /** Commit statuses of a commit, newest first (Vercel reports each project as "Vercel – <project>"). */
3119
+ async listCommitStatuses(sha) {
3120
+ const r = await this.request("GET", `${this.repoPath}/commits/${encodeRef(sha)}/statuses?per_page=100`);
3121
+ return r.map((s) => ({ context: s.context, state: s.state, target_url: s.target_url ?? null, description: s.description ?? null }));
3122
+ }
3123
+ /**
3124
+ * GitHub deployments of a commit with their latest status (environment "Preview – <project>", environment_url the
3125
+ * preview). Latest per environment only, so it costs one read plus one per environment.
3126
+ */
3127
+ async listDeployments(sha) {
3128
+ const list2 = await this.request("GET", `${this.repoPath}/deployments?sha=${encodeURIComponent(sha)}&per_page=30`);
3129
+ const latest = /* @__PURE__ */ new Map();
3130
+ for (const d of list2) if (!latest.has(d.environment)) latest.set(d.environment, d);
3131
+ return Promise.all(
3132
+ [...latest.values()].map(async (d) => {
3133
+ const st = await this.request("GET", `${this.repoPath}/deployments/${d.id}/statuses?per_page=1`).catch(() => []);
3134
+ const s = st[0];
3135
+ return { id: d.id, environment: d.environment, state: s?.state ?? null, environmentUrl: s?.environment_url || null };
3136
+ })
3137
+ );
3138
+ }
2803
3139
  async getPullRequest(n) {
2804
3140
  const r = await this.request("GET", `${this.repoPath}/pulls/${n}`);
2805
3141
  return { ...toSummary(r), body: r.body ?? "", mergeable: r.mergeable ?? null };
@@ -2826,7 +3162,44 @@ var GitHubClient = class _GitHubClient {
2826
3162
  "GET",
2827
3163
  `${this.repoPath}/commits/${encodeRef(ref)}/check-runs`
2828
3164
  );
2829
- return r.check_runs.map((c) => ({ id: c.id, name: c.name, status: c.status, conclusion: c.conclusion, url: c.html_url }));
3165
+ return r.check_runs.map((c) => ({ id: c.id, name: c.name, status: c.status, conclusion: c.conclusion, url: c.html_url, ...c.app?.slug ? { app: c.app.slug } : {} }));
3166
+ }
3167
+ /** Rulesets that apply to the repository, without their rules (GET /rulesets; read access is enough). */
3168
+ async listRulesets() {
3169
+ const r = await this.request("GET", `${this.repoPath}/rulesets?includes_parents=true&per_page=100`);
3170
+ return r.map(withRulesetUrl);
3171
+ }
3172
+ /** One ruleset with its rules. */
3173
+ async getRuleset(id) {
3174
+ return withRulesetUrl(await this.request("GET", `${this.repoPath}/rulesets/${id}`));
3175
+ }
3176
+ /** Creates a repository ruleset. Needs admin on the repo (fine-grained token: Administration read and write). */
3177
+ async createRuleset(payload) {
3178
+ this.assertToken("Protecting the library");
3179
+ return withRulesetUrl(await this.request("POST", `${this.repoPath}/rulesets`, payload));
3180
+ }
3181
+ /** Replaces a repository ruleset (same permissions as createRuleset). */
3182
+ async updateRuleset(id, payload) {
3183
+ this.assertToken("Protecting the library");
3184
+ return withRulesetUrl(await this.request("PUT", `${this.repoPath}/rulesets/${id}`, payload));
3185
+ }
3186
+ /**
3187
+ * Classic branch protection of `branch`: the settings, null when the branch has none (404), undefined when this
3188
+ * token cannot read them (403: the endpoint needs admin). On a 403 the public branch summary still tells
3189
+ * whether the branch is protected at all: `protected: false` means there is nothing to read, so null.
3190
+ */
3191
+ async getBranchProtection(branch) {
3192
+ try {
3193
+ return await this.request("GET", `${this.repoPath}/branches/${encodeRef(branch)}/protection`);
3194
+ } catch (err) {
3195
+ if (!(err instanceof GitHubError)) throw err;
3196
+ if (err.status === 404) return null;
3197
+ if (err.status === 403 || err.status === 401) {
3198
+ const b = await this.request("GET", `${this.repoPath}/branches/${encodeRef(branch)}`).catch(() => void 0);
3199
+ return b?.protected === false ? null : void 0;
3200
+ }
3201
+ throw err;
3202
+ }
2830
3203
  }
2831
3204
  /** Create a check run (needs a GitHub App token; personal tokens are refused by GitHub). */
2832
3205
  async createCheckRun(input) {
@@ -2868,7 +3241,9 @@ function toSummary(r) {
2868
3241
  ...typeof r.body === "string" ? { body: r.body } : {}
2869
3242
  };
2870
3243
  }
2871
- function friendlyMessage(status, detail) {
3244
+ function friendlyMessage(status, detail, info = {}) {
3245
+ if (info.auth === "rejected") return TOKEN_REJECTED_MESSAGE;
3246
+ if (info.auth === "missing-permission") return missingPermissionMessage(info.permission);
2872
3247
  switch (status) {
2873
3248
  case 401:
2874
3249
  return "GitHub rejected the token. It may be expired or mistyped: create a new one and save it in Settings.";
@@ -2888,6 +3263,10 @@ function friendlyMessage(status, detail) {
2888
3263
  return `GitHub error ${status}${detail ? `: ${detail}` : ""}`;
2889
3264
  }
2890
3265
  }
3266
+ function withRulesetUrl(r) {
3267
+ const href = r._links?.html?.href;
3268
+ return href && !r.html_url ? { ...r, html_url: href } : r;
3269
+ }
2891
3270
  function toIssue(r) {
2892
3271
  return {
2893
3272
  number: r.number,
@@ -2903,6 +3282,122 @@ function toIssue(r) {
2903
3282
  };
2904
3283
  }
2905
3284
 
3285
+ // ../core/src/protection-github.ts
3286
+ async function loadProtection(client, defaultBranch) {
3287
+ const rulesets = await client.listRulesets().then((list2) => Promise.all(list2.map((r) => r.enforcement === "disabled" || r.id === void 0 ? r : client.getRuleset(r.id)))).catch(() => null);
3288
+ const classic = await client.getBranchProtection(defaultBranch).catch(() => void 0);
3289
+ return protectionState(rulesets, classic, defaultBranch);
3290
+ }
3291
+ var rulesSettingsUrl = (owner, repo) => `https://github.com/${owner}/${repo}/settings/rules`;
3292
+
3293
+ // ../core/src/usage.ts
3294
+ var VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
3295
+ var IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
3296
+ function cssVarsIn(text) {
3297
+ const out = /* @__PURE__ */ new Set();
3298
+ for (const m of text.matchAll(VAR_RE)) out.add(m[1]);
3299
+ return [...out];
3300
+ }
3301
+ function importsIn(text) {
3302
+ const out = /* @__PURE__ */ new Set();
3303
+ for (const m of text.matchAll(IMPORT_RE)) out.add(m[1] ?? m[2]);
3304
+ return [...out];
3305
+ }
3306
+ var norm2 = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
3307
+ var isStory = (p) => /\.stories\.[cm]?[jt]sx?$/.test(p);
3308
+ var isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
3309
+ var isCss = (p) => /\.(css|scss|less)$/.test(p);
3310
+ var isScript = (p) => /\.[cm]?[jt]sx?$/.test(p);
3311
+ function componentOf(from, spec, componentsDir) {
3312
+ if (!spec.startsWith(".")) return null;
3313
+ const parts = from.split("/").slice(0, -1);
3314
+ for (const seg of spec.split("/")) {
3315
+ if (seg === "." || seg === "") continue;
3316
+ if (seg === "..") parts.pop();
3317
+ else parts.push(seg);
3318
+ }
3319
+ const abs = parts.join("/");
3320
+ if (!abs.startsWith(`${componentsDir}/`)) return null;
3321
+ return abs.slice(componentsDir.length + 1).split("/")[0] || null;
3322
+ }
3323
+ function componentForPath(path, componentsDir) {
3324
+ const dir = norm2(componentsDir);
3325
+ const p = norm2(path);
3326
+ if (!p.startsWith(`${dir}/`)) return null;
3327
+ const rest = p.slice(dir.length + 1).split("/");
3328
+ return rest.length >= 2 ? rest[0] : null;
3329
+ }
3330
+ function buildUsageIndex(files, componentsDir) {
3331
+ const dir = norm2(componentsDir);
3332
+ const modules = {};
3333
+ const vars = /* @__PURE__ */ new Map();
3334
+ const imports = /* @__PURE__ */ new Map();
3335
+ const storyImports = /* @__PURE__ */ new Map();
3336
+ const globalVars = {};
3337
+ const get = (m, k) => m.get(k) ?? m.set(k, /* @__PURE__ */ new Set()).get(k);
3338
+ for (const f of files) {
3339
+ const path = norm2(f.path);
3340
+ const id = componentForPath(path, dir);
3341
+ if (!id) {
3342
+ if (isCss(path)) {
3343
+ const v = cssVarsIn(f.text);
3344
+ if (v.length) globalVars[path] = v.sort();
3345
+ }
3346
+ continue;
3347
+ }
3348
+ const mod = modules[id] ??= { id, dir: `${dir}/${id}`, files: [], vars: [], imports: [], storyImports: [] };
3349
+ mod.files.push(path);
3350
+ if (isTest(path)) continue;
3351
+ for (const v of cssVarsIn(f.text)) get(vars, id).add(v);
3352
+ if (!isScript(path)) continue;
3353
+ for (const spec of importsIn(f.text)) {
3354
+ const dep = componentOf(path, spec, dir);
3355
+ if (!dep || dep === id) continue;
3356
+ get(isStory(path) ? storyImports : imports, id).add(dep);
3357
+ }
3358
+ }
3359
+ const byVar = {};
3360
+ for (const mod of Object.values(modules)) {
3361
+ mod.files.sort();
3362
+ mod.vars = [...vars.get(mod.id) ?? []].sort();
3363
+ mod.imports = [...imports.get(mod.id) ?? []].sort();
3364
+ mod.storyImports = [...storyImports.get(mod.id) ?? []].filter((d) => !mod.imports.includes(d)).sort();
3365
+ for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
3366
+ }
3367
+ for (const k of Object.keys(byVar)) byVar[k].sort();
3368
+ return { componentsDir: dir, modules, byVar, globalVars };
3369
+ }
3370
+ function impactedModules(index, input) {
3371
+ const reasons = /* @__PURE__ */ new Map();
3372
+ const add = (id, reason, indirect) => {
3373
+ const r = reasons.get(id);
3374
+ if (r) {
3375
+ if (!r.reasons.includes(reason)) r.reasons.push(reason);
3376
+ r.indirect &&= indirect;
3377
+ } else reasons.set(id, { reasons: [reason], indirect });
3378
+ };
3379
+ const changedVars = new Set(input.vars ?? []);
3380
+ for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
3381
+ for (const f of input.files ?? []) {
3382
+ const id = componentForPath(f, index.componentsDir);
3383
+ if (id && index.modules[id]) add(id, `file changed: ${norm2(f)}`, false);
3384
+ }
3385
+ const renderedBy = /* @__PURE__ */ new Map();
3386
+ for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
3387
+ const queue = [...reasons.keys()];
3388
+ while (queue.length) {
3389
+ const id = queue.shift();
3390
+ for (const parent of renderedBy.get(id) ?? []) {
3391
+ const known = reasons.has(parent);
3392
+ add(parent, `renders ${id}`, true);
3393
+ if (!known) queue.push(parent);
3394
+ }
3395
+ }
3396
+ const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
3397
+ const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
3398
+ return { modules, globalHits };
3399
+ }
3400
+
2906
3401
  // ../core/src/explorations.ts
2907
3402
  var EXPLORATION_USE_CASES = [
2908
3403
  { value: "user-test", label: "Prototype for a user test", hint: "A coded prototype on a product staging branch, with the real components and tokens." },
@@ -2917,7 +3412,8 @@ var EXPLORATION_STATUS_LABEL = { active: "Running", promoted: "Promoted to revie
2917
3412
  var REQUEST_KINDS = [
2918
3413
  { value: "new-component", label: "New component", hint: "A component the design system does not have yet." },
2919
3414
  { value: "new-variant", label: "New variant", hint: "A size, tone or state the component does not have yet." },
2920
- { value: "prop-change", label: "Prop change", hint: "Add, rename or change a prop or its default." },
3415
+ { value: "prop-change", label: "Option change (prop)", hint: "Add, rename or change an option (a prop in code) or its default." },
3416
+ { value: "structure", label: "Structure change", hint: "A part was added, removed or moved." },
2921
3417
  { value: "visual", label: "Visual change", hint: "Spacing, radius, layout or icon placement." },
2922
3418
  { value: "token-binding", label: "Token change", hint: "The component should paint with another token." },
2923
3419
  { value: "bug", label: "Bug", hint: "It looks or behaves differently from the guidance." },
@@ -2949,6 +3445,7 @@ function jiraIssueFields(r, jira, issueUrl) {
2949
3445
  version: 1,
2950
3446
  content: [
2951
3447
  para(text(r.summary.trim() || r.title)),
3448
+ ...r.line?.trim() ? [para(text(r.line.trim()))] : [],
2952
3449
  ...r.changes.map((c) => para(text(`${c.field}: ${c.before ?? "(none)"} -> ${c.after ?? "(removed)"}`))),
2953
3450
  para({ type: "text", text: "GitHub request", marks: [{ type: "link", attrs: { href: issueUrl } }] })
2954
3451
  ]
@@ -3213,14 +3710,14 @@ function union(a, b) {
3213
3710
  // ../visual/src/manifest.ts
3214
3711
  var COMMENT_MARKER = "<!-- bridge-visual-impact -->";
3215
3712
  function summarize(shots) {
3216
- const count = (s) => shots.filter((x) => x.status === s).length;
3713
+ const count2 = (s) => shots.filter((x) => x.status === s).length;
3217
3714
  return {
3218
3715
  shots: shots.length,
3219
- changed: count("changed"),
3220
- unchanged: count("unchanged"),
3221
- added: count("added"),
3222
- removed: count("removed"),
3223
- errors: count("error"),
3716
+ changed: count2("changed"),
3717
+ unchanged: count2("unchanged"),
3718
+ added: count2("added"),
3719
+ removed: count2("removed"),
3720
+ errors: count2("error"),
3224
3721
  maxPercent: shots.reduce((m, s) => Math.max(m, s.percent), 0)
3225
3722
  };
3226
3723
  }
@@ -3356,17 +3853,17 @@ var shotKey = (...parts) => parts.map((p) => p.toLowerCase().replace(/[^a-z0-9-]
3356
3853
 
3357
3854
  // ../visual/src/select.ts
3358
3855
  var NO_VISUAL = /(\.test\.[cm]?[jt]sx?|\.spec\.[cm]?[jt]sx?|\.docs\.json|\.mdx?|README[^/]*)$/i;
3359
- var norm2 = (p) => p.replace(/^\.\//, "").replace(/\\/g, "/");
3856
+ var norm3 = (p) => p.replace(/^\.\//, "").replace(/\\/g, "/");
3360
3857
  var isAllStates = (s) => /--all-states$/.test(s.id) || /^all ?states$/i.test(s.name);
3361
3858
  function storyComponent(s, componentsDir) {
3362
- return componentForPath(norm2(s.importPath), componentsDir);
3859
+ return componentForPath(norm3(s.importPath), componentsDir);
3363
3860
  }
3364
3861
  function selectImpact(input) {
3365
3862
  const { usage, stories } = input;
3366
3863
  const dir = usage.componentsDir;
3367
3864
  const lib = input.librarySrc.replace(/\/$/, "");
3368
3865
  const cap = input.maxStoriesPerComponent ?? 4;
3369
- const visualFiles = input.files.map(norm2).filter((f) => !NO_VISUAL.test(f));
3866
+ const visualFiles = input.files.map(norm3).filter((f) => !NO_VISUAL.test(f));
3370
3867
  const vars = [...new Set(input.tokens.map((t) => input.cssVar(t.path)))].sort();
3371
3868
  const impact2 = impactedModules(usage, { vars, files: visualFiles });
3372
3869
  let fallback = null;
@@ -3406,7 +3903,7 @@ function selectImpact(input) {
3406
3903
  return {
3407
3904
  tokens: input.tokens,
3408
3905
  vars,
3409
- files: input.files.map(norm2),
3906
+ files: input.files.map(norm3),
3410
3907
  components: components.map((c) => ({ id: c.id, reasons: c.reasons, indirect: c.indirect })),
3411
3908
  stories: [...new Set(picked)],
3412
3909
  fallback,
@@ -3425,7 +3922,7 @@ function storiesFromSource(files) {
3425
3922
  if (!title) continue;
3426
3923
  for (const m of f.text.matchAll(/export\s+const\s+([A-Z]\w*)\s*[:=]/g)) {
3427
3924
  const name = m[1];
3428
- out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${norm2(f.path)}` });
3925
+ out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${norm3(f.path)}` });
3429
3926
  }
3430
3927
  }
3431
3928
  return out;
@@ -4232,8 +4729,9 @@ Usage:
4232
4729
  GitHub token: the branch and the draft pull request on GitHub. Without one, in a git repo:
4233
4730
  a local branch only, nothing pushed
4234
4731
  bridge explore list [--all] [--json]
4235
- running and promoted explorations, with previews, the snapshot install command and the
4236
- requests implemented on each (--all: closed ones too)
4732
+ running and promoted explorations, with the preview links Vercel reports, the install
4733
+ command from the pull request (once the snapshot is built) and the requests implemented
4734
+ on each (--all: closed ones too)
4237
4735
  bridge explore promote <name|#pr> [--dry-run] [--json]
4238
4736
  ready for review, tier labels, exploration label and note removed (never merges)
4239
4737
  bridge explore close <name|#pr> [--json]
@@ -4279,8 +4777,7 @@ ${r.prBody}`);
4279
4777
  if (r.status === "started") {
4280
4778
  io.print(`Started ${r.branch}: draft pull request #${r.pr} ${r.url}`);
4281
4779
  io.print(`
4282
- Install it in a product staging branch:
4283
- ${r.install.command}`);
4780
+ ${r.install.note}`);
4284
4781
  for (const p of r.previews ?? []) io.print(`${p.label}: ${p.url}`);
4285
4782
  } else io.print(`Created the local branch ${r.branch} (${String(r.commit).slice(0, 7)}).`);
4286
4783
  io.print(`
@@ -4297,8 +4794,8 @@ Next: ${r.next}`);
4297
4794
  #${x.number} ${x.name} ${EXPLORATION_STATUS_LABEL[x.status]} ${x.branch} @${x.author} ${date(x.updatedAt)}`);
4298
4795
  if (x.purpose) io.print(` ${x.purpose}`);
4299
4796
  io.print(` ${x.url}`);
4300
- for (const p of x.previews) io.print(` ${p.label}: ${p.url}`);
4301
- if (x.install.packages.length) io.print(` Install: ${x.install.command}`);
4797
+ for (const p of x.previews) io.print(` ${p.label}: ${p.url ? `${p.url}${p.state === "building" ? " (building)" : p.state === "failed" ? " (build failed)" : ""}` : "link not reported by Vercel yet, see the pull request"}`);
4798
+ if (x.install) io.print(x.install.command ? ` Install: ${x.install.command}` : ` Install: ${x.install.note}`);
4302
4799
  if (x.requests.length) io.print(` Requests: ${x.requests.map((q) => `#${q.number}`).join(", ")}`);
4303
4800
  }
4304
4801
  for (const b of r.localBranches ?? []) io.print(`
@@ -4335,13 +4832,49 @@ ${EXPLORE_HELP}`);
4335
4832
  }
4336
4833
  }
4337
4834
 
4835
+ // src/protection.ts
4836
+ var UNKNOWN = { kind: "unknown", missing: ["review", "checks", "history", "deletion"] };
4837
+ async function protectionReport(opts) {
4838
+ let state = UNKNOWN;
4839
+ try {
4840
+ const client = new GitHubClient(opts.repo, opts.token ?? "", opts.fetch, { attempts: 1, baseDelayMs: 0, sleep: async () => {
4841
+ } });
4842
+ state = await loadProtection(client, opts.defaultBranch);
4843
+ } catch {
4844
+ state = UNKNOWN;
4845
+ }
4846
+ const [owner, name] = opts.repo.split("/");
4847
+ const lines = [`protection: ${protectionSentence(state)}`];
4848
+ if (state.kind === "protected" && state.url) lines.push(` ${state.url}`);
4849
+ if (state.kind === "unprotected" || state.kind === "partial") lines.push(` Protect it in Studio > Settings, or GitHub settings > Rules: ${rulesSettingsUrl(owner, name)}`);
4850
+ return { state, lines };
4851
+ }
4852
+
4853
+ // src/roles.ts
4854
+ async function rolesReport(opts) {
4855
+ if (!teamsIn(opts.owners).length) return { teams: [], lines: [] };
4856
+ const client = new GitHubClient(opts.repo, opts.token ?? "", opts.fetch, { attempts: 1, baseDelayMs: 0, sleep: async () => {
4857
+ } });
4858
+ const states = await new TeamDirectory(client).resolve(opts.owners);
4859
+ const teams = Object.values(states);
4860
+ const group = (entries) => entries.length ? entries.map((e) => entryLabel(e, states)).join(", ") : "nobody listed";
4861
+ const lines = [`roles: design owners ${group(opts.owners.design)}; tech owners ${group(opts.owners.tech)}`];
4862
+ for (const t of teams) if (t.status === "unreadable") lines.push(` warn team-unreadable ${t.message}`);
4863
+ return { teams, lines };
4864
+ }
4865
+
4338
4866
  // src/index.ts
4339
4867
  import { brand, BridgeContext as BridgeContext4, CONFIG_FILE as CONFIG_FILE2, core, detectConfig, GitRefSource, history as hist, LocalSource, ops, optionsFromArgv, serveStdio, toConfigJson, VERSION } from "@bridge-ds/mcp";
4340
4868
  var HELP = `bridge ${VERSION}: design system as code, for you and your Claude
4341
4869
 
4342
4870
  Usage:
4343
4871
  bridge init [--force] write bridge.config.json (detected) and .mcp.json for Claude Code
4344
- bridge check [--json] [--strict] validate tokens + contrast gate. Exit 0 ok, 1 failures, 2 setup error
4872
+ bridge check [--json] [--strict] [--offline]
4873
+ validate tokens + contrast gate, and say whether the library is
4874
+ protected (reviewed pull requests only) and who is in the GitHub
4875
+ teams named as owners, reporting teams it cannot read (neither
4876
+ changes the exit code; --offline skips both). Exit 0 ok, 1 failures,
4877
+ 2 setup error
4345
4878
  bridge tokens <query> [--json] search tokens by name, description or value
4346
4879
  bridge diff <base> [--json|--md] designer-readable token diff: working tree vs <base> (git ref)
4347
4880
  bridge propose --title "..." --set path=value [--set ...] [--file f | --mode theme=dark]
@@ -4408,7 +4941,7 @@ function parse(argv) {
4408
4941
  const [k, inline] = a.slice(2).split(/=(.*)/s, 2);
4409
4942
  const next = argv[i + 1];
4410
4943
  const fileArg = k === "json" && /\.json$/i.test(next ?? "") || k === "md" && /\.(md|markdown)$/i.test(next ?? "");
4411
- const takesValue = inline === void 0 && next !== void 0 && !next.startsWith("--") && (fileArg || !["force", "json", "md", "strict", "dry-run", "help", "fix", "write", "no-regress", "plan", "keep-unchanged", "restore-conflicts", "breaking", "claude", "all"].includes(k));
4944
+ const takesValue = inline === void 0 && next !== void 0 && !next.startsWith("--") && (fileArg || !["force", "json", "md", "strict", "dry-run", "help", "fix", "write", "no-regress", "plan", "keep-unchanged", "restore-conflicts", "breaking", "claude", "all", "offline"].includes(k));
4412
4945
  (out.flags[k] ??= []).push(inline ?? (takesValue ? argv[++i] : "true"));
4413
4946
  } else out._.push(a);
4414
4947
  }
@@ -4456,8 +4989,10 @@ async function check(a, root) {
4456
4989
  const c = await ops.checkContrast(ctx, {});
4457
4990
  const warnings = "warnings" in v ? v.warnings ?? [] : [];
4458
4991
  const failed = !v.valid || "failing" in c && (c.failing ?? 0) > 0 || has3(a, "strict") && warnings.length > 0;
4992
+ const protection = await protectionOf(a, root, ctx);
4993
+ const roles = await rolesOf(a, root, ctx);
4459
4994
  if (has3(a, "json")) {
4460
- print(JSON.stringify({ ok: !failed, validation: v, contrast: c }, null, 2));
4995
+ print(JSON.stringify({ ok: !failed, validation: v, contrast: c, ...protection ? { protection: protection.state } : {}, ...roles?.teams.length ? { teams: roles.teams } : {} }, null, 2));
4461
4996
  return failed ? 1 : 0;
4462
4997
  }
4463
4998
  print(`tokens: ${v.tokens} in ${v.files} files, ${v.modeCombinations} mode combinations`);
@@ -4469,9 +5004,27 @@ async function check(a, root) {
4469
5004
  const tight = c.tightest?.[0];
4470
5005
  if (tight) print(` tightest: ${tight.pair} [${tight.modes}] ${tight.ratio}:1 (min ${tight.min})`);
4471
5006
  } else print(`contrast: ${c.message}`);
5007
+ for (const line of protection?.lines ?? []) print(line);
5008
+ for (const line of roles?.lines ?? []) print(line);
4472
5009
  print(failed ? "FAIL" : "OK");
4473
5010
  return failed ? 1 : 0;
4474
5011
  }
5012
+ async function protectionOf(a, root, ctx) {
5013
+ if (has3(a, "offline") || process.env.BRIDGE_OFFLINE === "1") return void 0;
5014
+ const { config } = await ctx.config();
5015
+ const repo = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(root);
5016
+ if (!repo) return void 0;
5017
+ const token = process.env.BRIDGE_GITHUB_TOKEN || process.env.GITHUB_TOKEN || void 0;
5018
+ return protectionReport({ repo, defaultBranch: config.repo?.defaultBranch ?? "main", token });
5019
+ }
5020
+ async function rolesOf(a, root, ctx) {
5021
+ if (has3(a, "offline") || process.env.BRIDGE_OFFLINE === "1") return void 0;
5022
+ const { config } = await ctx.config();
5023
+ const repo = config.repo ? `${config.repo.owner}/${config.repo.name}` : await originRepo(root);
5024
+ if (!repo) return void 0;
5025
+ const token = process.env.BRIDGE_GITHUB_TOKEN || process.env.GITHUB_TOKEN || void 0;
5026
+ return rolesReport({ repo, owners: config.governance.owners, token });
5027
+ }
4475
5028
  async function tokens(a, root) {
4476
5029
  const q = a._.slice(1).join(" ");
4477
5030
  if (!q) throw new UsageError("bridge tokens <query>");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge-ds/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "bridge CLI: set up Bridge in a design system repo, check tokens in CI, search and diff tokens, propose changes, start the MCP server.",
6
6
  "license": "MIT",
@@ -43,8 +43,8 @@
43
43
  "build:package": "node ../../scripts/build-packages.mjs cli"
44
44
  },
45
45
  "dependencies": {
46
- "@bridge-ds/mcp": "^0.2.0",
47
- "@bridge-ds/visual": "^0.2.0",
46
+ "@bridge-ds/mcp": "^0.3.0",
47
+ "@bridge-ds/visual": "^0.2.1",
48
48
  "typescript": "5.9.3"
49
49
  },
50
50
  "devDependencies": {