@bridge-ds/mcp 0.8.3 → 0.8.5

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.
@@ -79,7 +79,7 @@ import {
79
79
  validateLayered,
80
80
  variantKeyOf,
81
81
  whoCanShipSentence
82
- } from "./chunk-CSJPMSY4.js";
82
+ } from "./chunk-PMZGEZGX.js";
83
83
 
84
84
  // src/lib.ts
85
85
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -2741,6 +2741,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
2741
2741
  }
2742
2742
  return t;
2743
2743
  };
2744
+ const body = async (sha, e) => (!client2.canWrite ? await client2.readRawFile(e.path, sha).catch(() => void 0) : void 0) ?? client2.getBlobText(e.sha);
2744
2745
  const root = config.tokens.root.replace(/\/$/, "");
2745
2746
  const prefix = root === "." || !root ? "" : `${root}/`;
2746
2747
  const limit = Math.max(1, opts.concurrency ?? 4);
@@ -2795,7 +2796,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
2795
2796
  const blobs = entries.filter((e) => e.type === "blob" && e.path.startsWith(prefix));
2796
2797
  const picked = new Set(selectTokenFiles(config, blobs.map((e) => e.path)));
2797
2798
  const wanted = blobs.filter((e) => picked.has(prefix ? e.path.slice(prefix.length) : e.path));
2798
- const texts = await pool(wanted, async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await client2.getBlobText(e.sha)]);
2799
+ const texts = await pool(wanted, async (e) => [prefix ? e.path.slice(prefix.length) : e.path, await body(sha, e)]);
2799
2800
  return new Map(texts);
2800
2801
  },
2801
2802
  async changesetPaths(sha) {
@@ -2805,7 +2806,7 @@ function gitHubHistoryBackend(client2, config, opts = {}) {
2805
2806
  },
2806
2807
  async readFile(sha, path) {
2807
2808
  const e = (await tree(sha)).find((x) => x.type === "blob" && x.path === path);
2808
- return e ? client2.getBlobText(e.sha) : null;
2809
+ return e ? body(sha, e) : null;
2809
2810
  }
2810
2811
  };
2811
2812
  }
@@ -3923,6 +3924,33 @@ function explorationRules(branch) {
3923
3924
  ];
3924
3925
  }
3925
3926
 
3927
+ // ../core/src/titles.ts
3928
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3929
+ function properWord(word, names3) {
3930
+ const bare = word.replace(/[^\p{L}\p{N}]+$/u, "");
3931
+ if (!bare) return false;
3932
+ if (names3.some((n) => n === bare)) return true;
3933
+ return new RegExp("\\p{Lu}", "u").test(bare.slice(1));
3934
+ }
3935
+ function lowerFirstWord(text, names3 = []) {
3936
+ const first = text.split(/\s+/)[0] ?? "";
3937
+ if (!first || properWord(first, names3)) return text;
3938
+ return `${text.charAt(0).toLowerCase()}${text.slice(1)}`;
3939
+ }
3940
+ function subjectTitle(subject, title, names3 = []) {
3941
+ const t = title.trim().replace(/\.$/, "");
3942
+ const keep = [subject, ...names3];
3943
+ const s = subject.trim();
3944
+ if (!s) return t;
3945
+ const m = new RegExp(`^${escape(s)}(?=$|[^\\p{L}\\p{N}])\\s*(:)?\\s*`, "iu").exec(t);
3946
+ if (m) {
3947
+ const rest = t.slice(m[0].length);
3948
+ if (!rest) return s;
3949
+ return m[1] ? `${s}: ${lowerFirstWord(rest, keep)}` : `${s} ${rest}`;
3950
+ }
3951
+ return `${s}: ${lowerFirstWord(t, keep)}`;
3952
+ }
3953
+
3926
3954
  // ../core/src/requests.ts
3927
3955
  var REQUEST_KINDS = [
3928
3956
  { value: "new-component", label: "New component", hint: "A component the design system does not have yet." },
@@ -3939,8 +3967,7 @@ var MARK2 = "bridge-request";
3939
3967
  var kindLabel = (k) => REQUEST_KINDS.find((x) => x.value === k)?.label ?? "Other";
3940
3968
  var kindSlug = (k) => `request: ${k}`;
3941
3969
  function requestIssueTitle(r) {
3942
- const t = r.title.trim().replace(/\.$/, "");
3943
- return `${r.component}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
3970
+ return subjectTitle(r.component, r.title);
3944
3971
  }
3945
3972
  function composeRequestIssue(r, opts = {}) {
3946
3973
  const rows = r.changes.length ? ["| What | Now | Requested |", "| --- | --- | --- |", ...r.changes.map((c) => `| ${cell2(c.field)} | ${cell2(c.before ?? "")} | ${cell2(c.after ?? "")} |`)] : [];
@@ -4549,15 +4576,36 @@ function reviewStatusKind(input) {
4549
4576
  if (input.mergeable === false) return out("conflict");
4550
4577
  if (input.ci?.state === "pending") return out("ci-running");
4551
4578
  if (!input.ci || !input.approvals) return out("unknown");
4552
- if (input.mergeable === null) return out("checking");
4553
4579
  const tier = input.tier ?? 3;
4554
4580
  if (tier >= 2) {
4555
4581
  if (!input.approvals.design) return out("needs-design");
4556
4582
  if (!input.approvals.tech) return out("needs-tech");
4557
4583
  } else if (!input.approvals.approvers.length) return out("needs-approval");
4584
+ if (input.mergeable === null) return out("checking");
4558
4585
  const gate = mergeGate({ tier: input.tier, ci: input.ci, approvals: input.approvals, draft: Boolean(input.draft), mergeable: input.mergeable, state: input.state ?? "open", merged_at: input.merged_at });
4559
4586
  return gate.allowed ? out("ready") : out("unknown");
4560
4587
  }
4588
+ var REVIEW_STATUS_LABEL = {
4589
+ merged: "Merged",
4590
+ closed: "Closed",
4591
+ draft: "Draft",
4592
+ "ci-failing": "Blocked by tests",
4593
+ "changes-requested": "Changes requested",
4594
+ "ci-running": "Tests running",
4595
+ "needs-design": "Needs design approval",
4596
+ "needs-tech": "Needs tech approval",
4597
+ "needs-approval": "Needs approval",
4598
+ conflict: "Conflict with main",
4599
+ checking: "Checking for conflicts",
4600
+ ready: "Ready to merge",
4601
+ unknown: "Status unknown"
4602
+ };
4603
+ var CI_STATE_LABEL = {
4604
+ pass: "Tests pass",
4605
+ fail: "Tests fail",
4606
+ pending: "Tests running",
4607
+ none: "No automated checks"
4608
+ };
4561
4609
  var BOTS = /* @__PURE__ */ new Set(["github-actions[bot]", "app/github-actions", "changeset-bot[bot]"]);
4562
4610
  var isReleasePull = (p) => p.head.ref.startsWith("changeset-release/");
4563
4611
  function isAutomationPull(p) {
@@ -5400,6 +5448,10 @@ var SUGGESTION_STATE_LABELS = {
5400
5448
  approved: "suggestion: approved",
5401
5449
  applying: "suggestion: applying"
5402
5450
  };
5451
+ var SUGGESTION_FINAL_LABELS = {
5452
+ applied: "suggestion: applied",
5453
+ declined: "suggestion: declined"
5454
+ };
5403
5455
  var SUGGESTION_STATUS_LABEL = {
5404
5456
  "needs-review": "Needs review",
5405
5457
  "changes-requested": "Changes requested",
@@ -5620,8 +5672,8 @@ function suggestionSubject(p) {
5620
5672
  return KIND_SUBJECT[p.kind];
5621
5673
  }
5622
5674
  function suggestionIssueTitle(p) {
5623
- const t = p.title.trim().replace(/\.$/, "");
5624
- return `${suggestionSubject(p)}: ${t.charAt(0).toLowerCase()}${t.slice(1)}`;
5675
+ const names3 = [...(p.docs ?? []).map((d) => d.component), ...p.componentSpec?.component ? [p.componentSpec.component] : []];
5676
+ return subjectTitle(suggestionSubject(p), p.title, names3);
5625
5677
  }
5626
5678
  function suggestionLabels(p, opts = {}) {
5627
5679
  return [
@@ -5655,9 +5707,22 @@ function suggestionStatus(i) {
5655
5707
  if (i.outdated) return "outdated";
5656
5708
  return i.review ?? "needs-review";
5657
5709
  }
5710
+ function withOutdated(status, outdated) {
5711
+ return outdated && (status === "needs-review" || status === "changes-requested" || status === "approved") ? "outdated" : status;
5712
+ }
5658
5713
  function suggestionStateLabel(status) {
5714
+ if (status === "applied" || status === "declined") return SUGGESTION_FINAL_LABELS[status];
5659
5715
  return status in SUGGESTION_STATE_LABELS ? SUGGESTION_STATE_LABELS[status] : null;
5660
5716
  }
5717
+ var SUGGESTION_STATUS_LABELS = [...Object.values(SUGGESTION_STATE_LABELS), ...Object.values(SUGGESTION_FINAL_LABELS)];
5718
+ function suggestionLabelChanges(labels, status) {
5719
+ if (status === "outdated") return { add: [], remove: [] };
5720
+ const want = suggestionStateLabel(status);
5721
+ return {
5722
+ add: want && !labels.includes(want) ? [want] : [],
5723
+ remove: SUGGESTION_STATUS_LABELS.filter((l) => l !== want && labels.includes(l))
5724
+ };
5725
+ }
5661
5726
  var bridgeMcpInstallCommand = (repo) => `claude mcp add bridge -s user -- npx -y @bridge-ds/mcp --repo ${repo?.trim() || "<owner/name>"}`;
5662
5727
  var MARK3 = "bridge-suggestion";
5663
5728
  var PART_SCHEMA = "bridge.suggestion-part/1";
@@ -6044,97 +6109,422 @@ function suggestionPathProblems(p, config) {
6044
6109
  return [...new Set(out)];
6045
6110
  }
6046
6111
 
6047
- // ../core/src/suggestions-github.ts
6048
- async function createSuggestionOnGitHub(gh, payload, opts = {}) {
6049
- const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
6050
- const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
6051
- for (const [i, part] of issue.parts.entries()) {
6052
- try {
6053
- await gh.createIssueComment(created.number, part);
6054
- } catch (err) {
6055
- const why = err instanceof Error ? err.message : String(err);
6056
- throw new GitHubError(err instanceof GitHubError ? err.status : 500, `Suggestion #${created.number} is incomplete: part ${i + 1} of ${issue.parts.length} could not be posted (${why}). Send it again, then close #${created.number}.`);
6057
- }
6058
- }
6059
- const warnings = [];
6060
- const missing = issue.labels.filter((l) => !created.labels.includes(l));
6061
- if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
6062
- return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
6112
+ // ../core/src/suggestion-apply.ts
6113
+ var SUGGESTION_APPLY_EXIT = {
6114
+ ready: 0,
6115
+ "already-applied": 0,
6116
+ conflict: 3,
6117
+ "not-approved": 1,
6118
+ blocked: 1,
6119
+ refused: 1
6120
+ };
6121
+ var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6122
+ var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6123
+ function tokenRoot(config) {
6124
+ const r = config.tokens.root.replace(/\/+$/, "");
6125
+ return r === "." ? "" : r;
6063
6126
  }
6064
- function linkedPullsOf(n, pulls) {
6065
- return pulls.filter((p) => closingIssueNumbers(p.body).includes(n)).map((p) => ({ number: p.number, url: p.url, state: p.state === "open" ? "open" : "closed", merged: p.state === "merged" }));
6127
+ function tokenFileOf(config, path) {
6128
+ const root = tokenRoot(config);
6129
+ if (root && !path.startsWith(`${root}/`)) return null;
6130
+ if (!selectTokenFiles(config, [path]).length) return null;
6131
+ return root ? path.slice(root.length + 1) : path;
6066
6132
  }
6067
- async function listSuggestionsOnGitHub(gh, opts = {}) {
6068
- const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
6069
- const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
6070
- return issues.map((i) => listItem(i, pulls));
6133
+ function planTokenEdit(set, e, changes) {
6134
+ const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6135
+ const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6136
+ const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6137
+ const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6138
+ const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6139
+ let result;
6140
+ try {
6141
+ result = applyTokenEdits(set, [e]);
6142
+ } catch (err) {
6143
+ const why = err instanceof Error ? err.message : String(err);
6144
+ switch (e.op) {
6145
+ case "rename": {
6146
+ const hasOld = defsAt(e.path).length > 0;
6147
+ const hasNew = set.defs.some((d) => d.path === e.newPath);
6148
+ if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6149
+ return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6150
+ }
6151
+ case "remove":
6152
+ if (!defsAt(e.path).length) return done("Already removed.");
6153
+ return conflict(why);
6154
+ case "add": {
6155
+ const existing = defsAt(e.path);
6156
+ if (!existing.length) return conflict(why);
6157
+ const want = (file) => {
6158
+ const c = recorded(file);
6159
+ if (c && c.after !== void 0) return c.after;
6160
+ return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6161
+ };
6162
+ const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6163
+ if (all) return done("Already added with the suggested value.");
6164
+ const d = existing.find((x) => !same2(x.value, want(x.file)));
6165
+ return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6166
+ }
6167
+ default:
6168
+ return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6169
+ }
6170
+ }
6171
+ if (!result.touched.length) return done("Already has the suggested value.");
6172
+ if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6173
+ for (const d of defsAt(e.path)) {
6174
+ const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6175
+ if (!c) continue;
6176
+ const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6177
+ if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
6178
+ }
6179
+ }
6180
+ const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6181
+ return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6071
6182
  }
6072
- function listItem(i, pulls) {
6073
- const linkedPrs = linkedPullsOf(i.number, pulls);
6074
- const cached = suggestionStateFromLabels(i.labels);
6075
- const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
6076
- return {
6077
- number: i.number,
6078
- title: i.title,
6079
- url: i.url,
6080
- author: i.author,
6081
- createdAt: i.createdAt,
6082
- state: i.state,
6083
- stateReason: i.stateReason,
6084
- labels: i.labels,
6085
- kind: suggestionKindFromLabels(i.labels),
6086
- tier: t ? Number(t) : void 0,
6087
- linkedPrs,
6088
- status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
6089
- };
6183
+ async function planFile(ws, baseSha, f) {
6184
+ const base = { type: "file", path: f.path };
6185
+ const cur = await ws.read(f.path);
6186
+ const curSha = cur?.sha ?? null;
6187
+ const theirs = f.afterSha === null ? null : f.content ?? null;
6188
+ if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6189
+ if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
6190
+ if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6191
+ if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6192
+ if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6193
+ const original = await ws.readAt(baseSha, f.path);
6194
+ if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6195
+ return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
6196
+ }
6197
+ const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6198
+ if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6199
+ if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6200
+ return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6090
6201
  }
6091
- async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
6092
- const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
6093
- const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
6094
- const linkedPrs = linkedPullsOf(n, pulls);
6095
- const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
6096
- const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
6097
- const status = suggestionStatus({
6098
- state: issue.state,
6099
- stateReason: issue.stateReason,
6100
- linkedPrs,
6101
- applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
6102
- outdated: opts.outdated,
6103
- review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
6202
+ async function planSuggestionApply(input) {
6203
+ const { parsed, config, workspace: ws } = input;
6204
+ const payload = parsed.payload;
6205
+ const notes = [];
6206
+ const out = (outcome, message, rest2 = {}) => ({
6207
+ outcome,
6208
+ exitCode: SUGGESTION_APPLY_EXIT[outcome],
6209
+ message,
6210
+ payloadSha: parsed.sha,
6211
+ payload,
6212
+ gate: null,
6213
+ ops: [],
6214
+ writes: [],
6215
+ checks: null,
6216
+ problems: [],
6217
+ notes,
6218
+ ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6219
+ ...rest2
6104
6220
  });
6105
- return { issue, parsed, comments, approvals, linkedPrs, applied, status };
6106
- }
6107
- async function syncSuggestionStateLabel(gh, n, labels, status) {
6108
- const want = suggestionStateLabel(status);
6109
- let ok2 = true;
6110
- for (const l of Object.values(SUGGESTION_STATE_LABELS)) {
6111
- if (l === want || !labels.includes(l)) continue;
6112
- try {
6113
- await gh.removeLabel(n, l);
6114
- } catch {
6115
- ok2 = false;
6221
+ const problems = suggestionPathProblems(payload, config);
6222
+ if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
6223
+ for (const f of payload.files ?? []) {
6224
+ if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
6225
+ else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
6226
+ }
6227
+ if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6228
+ if (parsed.edited && !input.acceptEdited) {
6229
+ return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
6230
+ problems: ["The payload no longer matches the sha Bridge recorded."]
6231
+ });
6232
+ }
6233
+ const root = tokenRoot(config);
6234
+ const tokenFiles = selectTokenFiles(config, await ws.list());
6235
+ const targets = [
6236
+ ...(payload.files ?? []).map((f) => f.path),
6237
+ ...(payload.docs ?? []).map((d) => d.path),
6238
+ ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
6239
+ ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
6240
+ ];
6241
+ if (ws.isSymlink) {
6242
+ const links = [];
6243
+ for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
6244
+ if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
6245
+ }
6246
+ const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
6247
+ const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
6248
+ const review = suggestionReviewState(payload.tier.tier, approvals);
6249
+ const missing = pendingApprovals(payload.tier.tier, approvals);
6250
+ const approved = review === "approved";
6251
+ const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
6252
+ const ops = [];
6253
+ const writes = /* @__PURE__ */ new Map();
6254
+ const current = /* @__PURE__ */ new Map();
6255
+ for (const f of tokenFiles) {
6256
+ const r = await ws.read(root ? `${root}/${f}` : f);
6257
+ if (r) current.set(f, r.text);
6258
+ }
6259
+ const before = new LayeredTokenSet(config, current);
6260
+ let set = before;
6261
+ const appliedEdits = [];
6262
+ for (const e of payload.tokens?.edits ?? []) {
6263
+ const r = planTokenEdit(set, e, payload.tokens.changes);
6264
+ ops.push(r.op);
6265
+ notes.push(...r.notes);
6266
+ if (r.op.state === "apply" && r.files) {
6267
+ set = new LayeredTokenSet(config, r.files);
6268
+ appliedEdits.push(e);
6116
6269
  }
6117
6270
  }
6118
- if (want && !labels.includes(want)) ok2 = await gh.addLabels(n, [want]) && ok2;
6119
- return ok2 ? null : `The state label on #${n} could not be updated; the status shown in Bridge is still right.`;
6120
- }
6121
- async function reviewSuggestionOnGitHub(gh, n, input, gov) {
6122
- if (input.event === "request-changes" && !input.message?.trim()) throw new GitHubError(422, "Say what should change, in a sentence or two: the author reads it.");
6123
- const before = await getSuggestionOnGitHub(gh, n, gov);
6124
- if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
6125
- if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
6126
- const sha = before.parsed.sha;
6127
- if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
6128
- const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
6129
- const body = composeSuggestionReviewComment({ event: input.event, role: input.role, payloadSha: sha, at: (input.at ?? /* @__PURE__ */ new Date()).toISOString(), by: by || void 0, message: input.message });
6130
- const posted = await gh.createIssueComment(n, body);
6131
- const after = await getSuggestionOnGitHub(gh, n, gov);
6132
- const status = after.status;
6133
- const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
6134
- const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
6135
- const warnings = [];
6136
- if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
6137
- if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
6271
+ for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
6272
+ for (const f of payload.files ?? []) {
6273
+ if (writes.has(f.path)) {
6274
+ notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
6275
+ continue;
6276
+ }
6277
+ const r = await planFile(ws, payload.base.sha, f);
6278
+ ops.push(r.op);
6279
+ if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
6280
+ }
6281
+ for (const d of payload.docs ?? []) {
6282
+ const base = { type: "docs", path: d.path };
6283
+ const docs = d.after;
6284
+ let text;
6285
+ try {
6286
+ const issues = validateDocs(docs);
6287
+ if (issues.length) {
6288
+ problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
6289
+ continue;
6290
+ }
6291
+ const cur = await ws.read(d.path);
6292
+ text = docsFileText(docs, cur?.text ?? null);
6293
+ if (cur && text === cur.text) {
6294
+ ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
6295
+ continue;
6296
+ }
6297
+ ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
6298
+ writes.set(d.path, text);
6299
+ } catch (err) {
6300
+ problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
6301
+ }
6302
+ }
6303
+ if (payload.componentSpec) {
6304
+ const s = payload.componentSpec;
6305
+ const text = `${JSON.stringify(s.spec, null, 2)}
6306
+ `;
6307
+ const cur = await ws.read(s.specPath);
6308
+ if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
6309
+ else {
6310
+ ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
6311
+ writes.set(s.specPath, text);
6312
+ }
6313
+ }
6314
+ const afterFiles = new Map(set.files);
6315
+ for (const [p, content] of writes) {
6316
+ const tf = tokenFileOf(config, p);
6317
+ if (!tf) continue;
6318
+ if (content === null) afterFiles.delete(tf);
6319
+ else afterFiles.set(tf, content);
6320
+ }
6321
+ const after = new LayeredTokenSet(config, afterFiles);
6322
+ const tokenChanges = diffLayered(before, after);
6323
+ const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6324
+ const prevErrors = new Set(validateLayered(before).map(issueKey4));
6325
+ const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
6326
+ let pairs = [];
6327
+ if (config.contrast && tokenChanges.length) {
6328
+ try {
6329
+ pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
6330
+ } catch {
6331
+ notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
6332
+ }
6333
+ }
6334
+ const modes = config.contrast?.modes;
6335
+ const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
6336
+ const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6337
+ const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
6338
+ const tier = classifyRisk({ tokenChanges: tokenChanges.map((c) => ({ kind: c.kind, path: c.path, ...c.kind === "changed" ? { descriptionOnly: c.fields.length > 0 && c.fields.every((x) => x === "description") } : {} })), files: otherFiles.map((path) => ({ path })), config });
6339
+ const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
6340
+ const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
6341
+ const rest = { gate, ops, checks, problems };
6342
+ if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
6343
+ const conflicts = ops.filter((o) => o.state === "conflict");
6344
+ if (conflicts.length) {
6345
+ return out("conflict", `Not applied: ${conflicts.length} change(s) conflict with the code now. Ask the author to refresh the suggestion on today's code, or apply those parts by hand.`, rest);
6346
+ }
6347
+ if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
6348
+ if (newErrors.length || newFailures.length) {
6349
+ const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
6350
+ return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
6351
+ }
6352
+ if (tierIncreased && !input.forceUnapproved) {
6353
+ return out("blocked", `Not applied: on today's code it is a tier ${tier.tier} change, it was approved as tier ${payload.tier.tier}. It needs a new approval.`, rest);
6354
+ }
6355
+ if (!approved && !input.forceUnapproved) {
6356
+ const who = review === "changes-requested" ? `changes requested by ${approvals.changesRequested.map((l) => `@${l}`).join(", ")}` : `missing ${missing.map((m) => m === "approval" ? "an approval" : `${m} approval`).join(" and ")}`;
6357
+ return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
6358
+ }
6359
+ const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
6360
+ return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
6361
+ ...rest,
6362
+ gate: { ...gate, forced },
6363
+ writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
6364
+ });
6365
+ }
6366
+ function suggestionAppliedComment(plan, where2) {
6367
+ return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
6368
+ }
6369
+ function suggestionApplyTable(plan) {
6370
+ const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
6371
+ const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
6372
+ return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
6373
+ }
6374
+
6375
+ // ../core/src/suggestions-github.ts
6376
+ async function createSuggestionOnGitHub(gh, payload, opts = {}) {
6377
+ const issue = composeSuggestionIssue(payload, { repo: gh.fullName, ...opts });
6378
+ const created = await gh.createIssue({ title: issue.title, body: issue.body, labels: issue.labels, assignees: issue.assignees });
6379
+ for (const [i, part] of issue.parts.entries()) {
6380
+ try {
6381
+ await gh.createIssueComment(created.number, part);
6382
+ } catch (err) {
6383
+ const why = err instanceof Error ? err.message : String(err);
6384
+ throw new GitHubError(err instanceof GitHubError ? err.status : 500, `Suggestion #${created.number} is incomplete: part ${i + 1} of ${issue.parts.length} could not be posted (${why}). Send it again, then close #${created.number}.`);
6385
+ }
6386
+ }
6387
+ const warnings = [];
6388
+ const missing = issue.labels.filter((l) => !created.labels.includes(l));
6389
+ if (created.labels.length && missing.length) warnings.push(`GitHub did not keep these labels on #${created.number}: ${missing.join(", ")}.`);
6390
+ return { number: created.number, url: created.url, sha: issue.sha, parts: issue.parts.length, warnings };
6391
+ }
6392
+ function linkedPullsOf(n, pulls) {
6393
+ return pulls.filter((p) => closingIssueNumbers(p.body).includes(n)).map((p) => ({ number: p.number, url: p.url, state: p.state === "open" ? "open" : "closed", merged: p.state === "merged" }));
6394
+ }
6395
+ async function listSuggestionsOnGitHub(gh, opts = {}) {
6396
+ const issues = await gh.listIssues({ label: SUGGESTION_LABEL, state: opts.state ?? "open", perPage: opts.perPage ?? 50 });
6397
+ const pulls = opts.pulls ?? (issues.length ? await gh.listPullRequests("all").catch(() => []) : []);
6398
+ return issues.map((i) => listItem(i, pulls));
6399
+ }
6400
+ function listItem(i, pulls) {
6401
+ const linkedPrs = linkedPullsOf(i.number, pulls);
6402
+ const cached = suggestionStateFromLabels(i.labels);
6403
+ const t = i.labels.map((l) => /^tier: ([123])$/.exec(l)?.[1]).find(Boolean);
6404
+ return {
6405
+ number: i.number,
6406
+ title: i.title,
6407
+ url: i.url,
6408
+ author: i.author,
6409
+ createdAt: i.createdAt,
6410
+ state: i.state,
6411
+ stateReason: i.stateReason,
6412
+ labels: i.labels,
6413
+ kind: suggestionKindFromLabels(i.labels),
6414
+ tier: t ? Number(t) : void 0,
6415
+ linkedPrs,
6416
+ status: suggestionStatus({ state: i.state, stateReason: i.stateReason, linkedPrs, applied: cached === "applying", review: cached === "applying" ? void 0 : cached })
6417
+ };
6418
+ }
6419
+ async function getSuggestionOnGitHub(gh, n, gov, opts = {}) {
6420
+ const [issue, comments, pulls] = await Promise.all([gh.getIssue(n), gh.listIssueComments(n), opts.pulls ? Promise.resolve(opts.pulls) : gh.listPullRequests("all").catch(() => [])]);
6421
+ const parsed = parseSuggestionIssue(issue.body, { comments, author: issue.author || void 0 });
6422
+ const linkedPrs = linkedPullsOf(n, pulls);
6423
+ const applied = comments.flatMap((c) => parseSuggestionAppliedComment(c.body) ?? []);
6424
+ const approvals = parsed.ok ? summarizeSuggestionApprovals(comments, gov, { payloadSha: parsed.sha, author: issue.author }) : void 0;
6425
+ const status = suggestionStatus({
6426
+ state: issue.state,
6427
+ stateReason: issue.stateReason,
6428
+ linkedPrs,
6429
+ applied: parsed.ok && applied.some((a) => a.payloadSha === parsed.sha),
6430
+ outdated: opts.outdated,
6431
+ review: parsed.ok && approvals ? suggestionReviewState(parsed.payload.tier.tier, approvals) : void 0
6432
+ });
6433
+ return { issue, parsed, comments, approvals, linkedPrs, applied, status };
6434
+ }
6435
+ async function syncSuggestionStateLabel(gh, n, labels, status) {
6436
+ const { add, remove } = suggestionLabelChanges(labels, status);
6437
+ let ok2 = true;
6438
+ for (const l of remove) {
6439
+ try {
6440
+ await gh.removeLabel(n, l);
6441
+ } catch {
6442
+ ok2 = false;
6443
+ }
6444
+ }
6445
+ if (add.length) ok2 = await gh.addLabels(n, add) && ok2;
6446
+ return ok2 ? null : `The status label on #${n} could not be updated; the status shown in Bridge is still right.`;
6447
+ }
6448
+ async function markSuggestionsAppliedOnGitHub(gh, pr) {
6449
+ const applied = [];
6450
+ const warnings = [];
6451
+ for (const n of closingIssueNumbers(pr.body)) {
6452
+ try {
6453
+ const issue = await gh.getIssue(n);
6454
+ if (!issue.labels.includes(SUGGESTION_LABEL)) continue;
6455
+ const w = await syncSuggestionStateLabel(gh, n, issue.labels, "applied");
6456
+ if (w) warnings.push(w);
6457
+ else applied.push(n);
6458
+ } catch (err) {
6459
+ warnings.push(`Suggestion #${n} could not be marked applied (${err instanceof Error ? err.message : String(err)}).`);
6460
+ }
6461
+ }
6462
+ return { applied, warnings };
6463
+ }
6464
+ function githubSuggestionWorkspace(gh, ref) {
6465
+ let files;
6466
+ return {
6467
+ read: async (path) => await gh.getFile(path, ref) ?? null,
6468
+ readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
6469
+ list: () => files ??= gh.listFiles(ref)
6470
+ };
6471
+ }
6472
+ function suggestionTouches(payload, config, path) {
6473
+ if (path === CONFIG_FILE) return true;
6474
+ if ((payload.files ?? []).some((f) => f.path === path)) return true;
6475
+ if ((payload.docs ?? []).some((d) => d.path === path)) return true;
6476
+ if (payload.componentSpec?.specPath === path) return true;
6477
+ return Boolean(payload.tokens?.edits.length) && selectTokenFiles(config, [path]).length > 0;
6478
+ }
6479
+ var MAX_COMPARE_FILES = 300;
6480
+ async function suggestionStalenessOnGitHub(gh, d, input) {
6481
+ const none = (mainSha2, mainMoved) => ({ mainSha: mainSha2, mainMoved, outdated: false, conflicts: [], plan: null });
6482
+ if (!d.parsed.ok || d.issue.state !== "open") return none(null, false);
6483
+ const payload = d.parsed.payload;
6484
+ const ref = input.ref ?? (payload.base.ref || input.config.repo?.defaultBranch || "main");
6485
+ const mainSha = await gh.getBranchSha(ref).catch(() => void 0) ?? null;
6486
+ if (!mainSha) return none(null, false);
6487
+ if (mainSha === payload.base.sha) return none(mainSha, false);
6488
+ const changed = await gh.compare(payload.base.sha, mainSha).catch(() => null);
6489
+ if (changed && changed.files.length < MAX_COMPARE_FILES && !changed.files.some((f) => suggestionTouches(payload, input.config, f.filename))) return none(mainSha, true);
6490
+ try {
6491
+ const plan = await planSuggestionApply({
6492
+ parsed: d.parsed,
6493
+ config: input.config,
6494
+ comments: d.comments,
6495
+ governance: input.governance,
6496
+ author: d.issue.author || null,
6497
+ // Only whether it still applies: approvals and a hand edit are reported apart.
6498
+ forceUnapproved: true,
6499
+ acceptEdited: true,
6500
+ workspace: githubSuggestionWorkspace(gh, mainSha)
6501
+ });
6502
+ const conflicts = plan.ops.filter((o) => o.state === "conflict");
6503
+ return { mainSha, mainMoved: true, outdated: conflicts.length > 0, conflicts, plan };
6504
+ } catch {
6505
+ return none(mainSha, true);
6506
+ }
6507
+ }
6508
+ function stalenessStatus(status, staleness) {
6509
+ return withOutdated(status, staleness?.outdated);
6510
+ }
6511
+ async function reviewSuggestionOnGitHub(gh, n, input, gov) {
6512
+ if (input.event === "request-changes" && !input.message?.trim()) throw new GitHubError(422, "Say what should change, in a sentence or two: the author reads it.");
6513
+ const before = await getSuggestionOnGitHub(gh, n, gov);
6514
+ if (!before.parsed.ok) throw new GitHubError(422, `#${n} cannot be reviewed: ${before.parsed.message}`);
6515
+ if (before.issue.state === "closed") throw new GitHubError(422, `#${n} is closed. Reopen it on GitHub to review it again.`);
6516
+ const sha = before.parsed.sha;
6517
+ if (input.payloadSha && input.payloadSha !== sha) throw new GitHubError(409, `#${n} changed since you opened it. Reload it and review the new version.`);
6518
+ const by = input.by ?? (await gh.getViewer().catch(() => ({ login: "" }))).login;
6519
+ const body = composeSuggestionReviewComment({ event: input.event, role: input.role, payloadSha: sha, at: (input.at ?? /* @__PURE__ */ new Date()).toISOString(), by: by || void 0, message: input.message });
6520
+ const posted = await gh.createIssueComment(n, body);
6521
+ const after = await getSuggestionOnGitHub(gh, n, gov);
6522
+ const status = after.status;
6523
+ const who = (after.comments.find((c) => c.id === posted.id)?.author ?? by).toLowerCase();
6524
+ const counts = input.event === "comment" || [...after.approvals?.approvers ?? [], ...after.approvals?.changesRequested ?? []].some((l) => l.toLowerCase() === who);
6525
+ const warnings = [];
6526
+ if (!counts) warnings.push("Your review is posted but does not count: only the owners listed in bridge.config.json approve suggestions.");
6527
+ if (before.parsed.edited) warnings.push(`#${n} was edited by hand after Bridge wrote it: check the machine-readable block before applying it.`);
6138
6528
  const labelWarning = await syncSuggestionStateLabel(gh, n, before.issue.labels, status);
6139
6529
  if (labelWarning) warnings.push(labelWarning);
6140
6530
  return { commentUrl: posted.url, payloadSha: sha, status, counts, warnings };
@@ -6143,8 +6533,12 @@ async function linkSuggestionPrOnGitHub(gh, n, link) {
6143
6533
  if (!link.pr && !link.branch && !link.commit) throw new GitHubError(422, "Say where it is applied: a pull request number, a branch or a commit.");
6144
6534
  const issue = await gh.getIssue(n);
6145
6535
  await gh.addComment(n, composeSuggestionAppliedComment({ by: link.by, branch: link.branch, commit: link.commit, pr: link.pr, payloadSha: link.payloadSha, forced: link.forced, at: (link.at ?? /* @__PURE__ */ new Date()).toISOString() }));
6146
- const warning = await syncSuggestionStateLabel(gh, n, issue.labels, "applying");
6147
- return { warnings: warning ? [warning] : [] };
6536
+ const status = appliedStatus(issue, link.merged);
6537
+ const warning = await syncSuggestionStateLabel(gh, n, issue.labels, status);
6538
+ return { warnings: warning ? [warning] : [], status };
6539
+ }
6540
+ function appliedStatus(issue, merged) {
6541
+ return merged || issue.state === "closed" && issue.stateReason === "completed" ? "applied" : "applying";
6148
6542
  }
6149
6543
  async function declineSuggestionOnGitHub(gh, n, reason, by) {
6150
6544
  const issue = await gh.getIssue(n);
@@ -6675,472 +7069,209 @@ async function switchToPublishedOnGitHub(gh, pr) {
6675
7069
  return { status: "switched", said: `Your branch now uses the published version from ${pull.baseRef}.` };
6676
7070
  }
6677
7071
 
6678
- // ../core/src/token-proposal.ts
6679
- var issueKey2 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
6680
- function planTokenProposal(input) {
6681
- const { before, config } = input;
6682
- const r = applyTokenEdits(before, input.edits);
6683
- const after = new LayeredTokenSet(before.config, r.files);
6684
- const changes = diffLayered(before, after);
6685
- if (!changes.length) return null;
6686
- const prevErrors = new Set(validateLayered(before).map(issueKey2));
6687
- const newErrors = validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
6688
- const modes = config.contrast?.modes;
6689
- const contrast = input.pairs.length ? contrastDelta(runLayeredContrast(before, input.pairs, modes), runLayeredContrast(after, input.pairs, modes)) : [];
6690
- const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
6691
- const impact = layeredImpact(before, after, changes);
6692
- const tier = classifyLayered(changes, [], config);
6693
- const summary2 = input.summary?.trim() || input.title;
6694
- const body = [composeProposalBody({ summary: summary2, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: input.tool ?? "Bridge MCP" }), ...input.bodyExtra ?? []].filter(Boolean).join("\n\n");
6695
- const slug2 = slugify(input.title, 48) || "change";
6696
- const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slug2}.md`;
6697
- const touched = r.touched.map((f) => after.repoPath(f));
6698
- const files = [
6699
- ...r.touched.map((f) => ({ path: after.repoPath(f), content: after.files.get(f) })),
6700
- ...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
6701
- ];
6702
- const blocked = newErrors.length || newFailures.length ? [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ") : null;
6703
- return { title: input.title, summary: summary2, edits: input.edits, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
6704
- }
6705
- async function writeTokenProposalOnGitHub(gh, plan, opts) {
6706
- if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
6707
- const jira = opts.jira ?? null;
6708
- let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
6709
- for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
6710
- const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
6711
-
6712
- Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
6713
- const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6714
- const draft = plan.tier.tier >= 2;
6715
- const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
6716
- await gh.addLabels(pr.number, labels);
6717
- return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
6718
- }
6719
- var tokenLabels = (plan, before = [], after = []) => [
6720
- .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
6721
- ];
6722
- function tokenSuggestionPayload(plan, ctx) {
6723
- const tokenFiles = new Set(plan.touched);
6724
- const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
6725
- return {
6726
- schema: SUGGESTION_SCHEMA,
6727
- kind: "token-change",
6728
- title: plan.title,
6729
- summary: plan.summary,
6730
- source: ctx.source,
6731
- ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
6732
- createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6733
- base: ctx.base,
6734
- tier: { tier: plan.tier.tier, bump: plan.tier.bump },
6735
- tokens: {
6736
- edits: plan.edits,
6737
- changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
6738
- },
6739
- ...files.length ? { files } : {},
6740
- checks: {
6741
- newErrors: plan.newErrors.length,
6742
- contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
6743
- blocked: plan.blocked,
6744
- impact: plan.impact.total
6745
- },
6746
- ...ctx.links ? { links: ctx.links } : {},
6747
- ...ctx.previews?.length ? { previews: ctx.previews } : {}
6748
- };
6749
- }
6750
- async function suggestTokenProposalOnGitHub(gh, plan, opts) {
6751
- if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
6752
- const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
6753
- if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
6754
- const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
6755
- const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
6756
- const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
6757
- const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
6758
- return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
6759
- }
6760
-
6761
- // ../core/src/figma-push-github.ts
6762
- var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
6763
- var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
6764
- function componentSpecSuggestionPayload(d, plan, ctx) {
6765
- const specPath = figmaSpecPath(d);
6766
- const spec = JSON.parse(formatFigmaSpec(d, plan));
6767
- const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
6768
- const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
6769
- const figma = d.figma?.fileKey || d.figma?.nodeId || d.figma?.url ? { ...d.figma.fileKey ? { fileKey: d.figma.fileKey } : {}, ...d.figma.nodeId ? { nodeId: d.figma.nodeId } : {}, ...d.figma.url ? { url: d.figma.url } : {}, ...figmaNodes(d) } : void 0;
6770
- const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
6771
- return {
6772
- schema: SUGGESTION_SCHEMA,
6773
- kind: "component-spec",
6774
- title: d.title,
6775
- summary: d.summary.trim() || d.title,
6776
- source: pushSource(d),
6777
- ...by ? { suggestedBy: by } : {},
6778
- createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
6779
- base: ctx.base,
6780
- tier: specTier(d),
6781
- componentSpec: { component: d.component, specPath, spec, brief },
6782
- checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
6783
- ...figma ? { links: { figma } } : {},
6784
- ...previews.length ? { previews } : {}
6785
- };
6786
- }
6787
- function figmaNodes(d) {
6788
- const f = d.figma;
6789
- if (!f) return {};
6790
- const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
6791
- const nodes = [];
6792
- if (f.before?.nodeId) nodes.push({ nodeId: f.before.nodeId, state: "before", ...url(f.before.nodeId, f.before.url) ? { url: url(f.before.nodeId, f.before.url) } : {}, ...f.before.nodeName ? { name: f.before.nodeName } : {} });
6793
- if (f.nodeId) nodes.push({ nodeId: f.nodeId, state: "after", ...url(f.nodeId, f.url) ? { url: url(f.nodeId, f.url) } : {}, ...f.nodeName ? { name: f.nodeName } : {} });
6794
- return nodes.length ? { nodes } : {};
6795
- }
6796
- async function openComponentPushOnGitHub(gh, input) {
6797
- const d = input.diff;
6798
- if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
6799
- const plan = input.plan ?? planComponentPush(d, input.tokens);
6800
- if (plan.route === "manual") return { status: "not-sent", plan, link: null };
6801
- if (input.dryRun) return { status: "dry-run", plan, link: null };
6802
- const mode = input.mode ?? "direct";
6803
- if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a component change needs a GitHub token (issues: write)." : "Sending a component change needs a GitHub token (contents and pull requests: write).");
6804
- const origin = {
6805
- component: d.component,
6806
- figmaName: d.figmaName,
6807
- route: plan.route,
6808
- kind: d.kind,
6809
- ...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
6810
- ...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
6811
- ...d.figma?.url ? { url: d.figma.url } : {},
6812
- ...d.requestedBy ? { requestedBy: d.requestedBy } : {}
6813
- };
6814
- if (plan.route === "token-change") {
6815
- if (!input.writeTokens) throw new GitHubError(422, "This change is a token change: send it where the token files are read (the DS Bridge plugin, bridge_push_figma_component or `bridge figma push`).");
6816
- const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
6817
- const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
6818
- await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
6819
- if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
6820
- return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
6821
- }
6822
- if (mode === "suggest") {
6823
- const sha = input.baseSha ?? await gh.getBranchSha(input.base);
6824
- if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
6825
- const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
6826
- const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
6827
- return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
6828
- }
6829
- const spec = figmaSpecPath(d);
6830
- const o = { ...origin, spec, status: "spec" };
6831
- let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
6832
- for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
6833
- await gh.commitToNewBranch({
6834
- base: input.base,
6835
- branch,
6836
- message: `chore(figma): spec for ${figmaBranchTitle(d)}
6837
-
6838
- Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
6839
- files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
6840
- });
6841
- const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
6842
- await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
6843
- return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
6844
- }
6845
- async function listFigmaChangesOnGitHub(gh, opts = {}) {
6846
- const pulls = await gh.listPullRequests(opts.state ?? "open");
6847
- const out = [];
6848
- for (const p of pulls) {
6849
- if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
6850
- const origin = parseFigmaOrigin(p.body);
6851
- if (!origin) continue;
6852
- out.push({ number: p.number, title: p.title, url: p.url, branch: p.headRef, origin, status: figmaPushStatus({ state: p.state, draft: p.draft, labels: p.labels, route: origin.route }) });
6853
- }
6854
- return out;
6855
- }
6856
- async function figmaChangeOnGitHub(gh, n, opts = {}) {
6857
- const pull = await gh.getPullRequest(n);
6858
- const origin = parseFigmaOrigin(pull.body);
6859
- if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
6860
- const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
6861
- if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
6862
- const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
6863
- const spec = parseFigmaSpec(text);
6864
- if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
6865
- const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
6866
- return { pull, origin, status, spec, brief };
6867
- }
6868
- async function markFigmaChangeDraftedOnGitHub(gh, n) {
6869
- if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
6870
- const pull = await gh.getPullRequest(n);
6871
- const origin = parseFigmaOrigin(pull.body);
6872
- if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
6873
- if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
6874
- const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
6875
- if (!files.length) throw new GitHubError(422, `${pull.headRef} only carries the spec: write the component code there first, push it, then mark it drafted.`);
6876
- await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
6877
- await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
6878
- await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
6879
- const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
6880
- return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
6881
- }
6882
-
6883
- // ../core/src/suggestion-apply.ts
6884
- var SUGGESTION_APPLY_EXIT = {
6885
- ready: 0,
6886
- "already-applied": 0,
6887
- conflict: 3,
6888
- "not-approved": 1,
6889
- blocked: 1,
6890
- refused: 1
6891
- };
6892
- var same2 = (a, b) => canonicalJson(a) === canonicalJson(b);
6893
- var show3 = (v) => v === void 0 ? "nothing" : typeof v === "string" ? v : JSON.stringify(v);
6894
- function tokenRoot(config) {
6895
- const r = config.tokens.root.replace(/\/+$/, "");
6896
- return r === "." ? "" : r;
6897
- }
6898
- function tokenFileOf(config, path) {
6899
- const root = tokenRoot(config);
6900
- if (root && !path.startsWith(`${root}/`)) return null;
6901
- if (!selectTokenFiles(config, [path]).length) return null;
6902
- return root ? path.slice(root.length + 1) : path;
6903
- }
6904
- function planTokenEdit(set, e, changes) {
6905
- const base = { type: "token", path: e.path, edit: e, ...e.file ? { file: e.file } : {} };
6906
- const defsAt = (path) => set.defs.filter((d) => d.path === path && (!e.file || d.file === e.file));
6907
- const recorded = (file, path = e.path) => changes.find((c) => c.path === path && c.file === file && (e.op === "rename" ? c.kind === "renamed" : true));
6908
- const conflict = (detail, extra = {}) => ({ op: { ...base, state: "conflict", detail, ...extra }, notes: [] });
6909
- const done = (detail) => ({ op: { ...base, state: "already-applied", detail }, notes: [] });
6910
- let result;
6911
- try {
6912
- result = applyTokenEdits(set, [e]);
6913
- } catch (err) {
6914
- const why = err instanceof Error ? err.message : String(err);
6915
- switch (e.op) {
6916
- case "rename": {
6917
- const hasOld = defsAt(e.path).length > 0;
6918
- const hasNew = set.defs.some((d) => d.path === e.newPath);
6919
- if (!hasOld && hasNew) return done(`Already renamed to ${e.newPath}.`);
6920
- return conflict(hasOld && hasNew ? `Both ${e.path} and ${e.newPath} exist now: rename it by hand.` : `${e.path} no longer exists: ${why}`);
6921
- }
6922
- case "remove":
6923
- if (!defsAt(e.path).length) return done("Already removed.");
6924
- return conflict(why);
6925
- case "add": {
6926
- const existing = defsAt(e.path);
6927
- if (!existing.length) return conflict(why);
6928
- const want = (file) => {
6929
- const c = recorded(file);
6930
- if (c && c.after !== void 0) return c.after;
6931
- return coerceValue(e.value, e.type ?? existing.find((d2) => d2.file === file)?.type);
6932
- };
6933
- const all = existing.every((d2) => same2(d2.value, want(d2.file)));
6934
- if (all) return done("Already added with the suggested value.");
6935
- const d = existing.find((x) => !same2(x.value, want(x.file)));
6936
- return conflict(`Someone added ${e.path} with another value since.`, { file: d.file, after: want(d.file), now: d.value });
6937
- }
6938
- default:
6939
- return conflict(defsAt(e.path).length ? why : `${e.path} no longer exists, so its value cannot be set.`);
6940
- }
6941
- }
6942
- if (!result.touched.length) return done("Already has the suggested value.");
6943
- if (e.op === "set" || e.op === "remove" || e.op === "rename") {
6944
- for (const d of defsAt(e.path)) {
6945
- const c = recorded(d.file, e.op === "rename" ? e.newPath ?? e.path : e.path);
6946
- if (!c) continue;
6947
- const okNow = same2(d.value, c.before) || e.op === "set" && same2(d.value, c.after);
6948
- if (!okNow) return conflict(`${e.path} changed since it was suggested (now ${show3(d.value)}, suggested from ${show3(c.before)}).`, { file: d.file, before: c.before, after: c.after, now: d.value });
6949
- }
6950
- }
6951
- const verb = e.op === "set" ? "Set" : e.op === "add" ? "Add" : e.op === "remove" ? "Remove" : `Rename to ${e.newPath ?? ""}`;
6952
- return { op: { ...base, state: "apply", detail: `${verb} in ${result.touched.join(", ")}.` }, files: result.files, notes: result.notes };
6953
- }
6954
- async function planFile(ws, baseSha, f) {
6955
- const base = { type: "file", path: f.path };
6956
- const cur = await ws.read(f.path);
6957
- const curSha = cur?.sha ?? null;
6958
- const theirs = f.afterSha === null ? null : f.content ?? null;
6959
- if (curSha === f.afterSha) return { op: { ...base, state: "already-applied", detail: f.afterSha === null ? "Already deleted." : "Already has the suggested content." } };
6960
- if (curSha === f.beforeSha) return { op: { ...base, state: "apply", detail: f.afterSha === null ? "Delete it." : f.beforeSha === null ? "Create it." : "Replace it with the suggested content." }, content: theirs };
6961
- if (f.beforeSha === null) return { op: { ...base, state: "conflict", detail: "The suggestion creates this file, but a different one exists now." } };
6962
- if (!cur) return { op: { ...base, state: "conflict", detail: "The suggestion changes this file, but it was deleted since." } };
6963
- if (theirs === null) return { op: { ...base, state: "conflict", detail: "The suggestion deletes this file, but it changed since." } };
6964
- const original = await ws.readAt(baseSha, f.path);
6965
- if (original === null || gitBlobSha(original) !== f.beforeSha || !ws.merge) {
6966
- return { op: { ...base, state: "conflict", detail: original === null ? "The file changed since and its version at the suggestion base cannot be read, so it cannot be merged." : "The file changed since and cannot be merged here." } };
6967
- }
6968
- const merged = await ws.merge({ path: f.path, ours: cur.text, base: original, theirs });
6969
- if (!merged.clean) return { op: { ...base, state: "conflict", detail: `The file changed since and the merge has ${merged.conflicts} conflict(s).`, hunks: merged.conflicts } };
6970
- if (merged.text === cur.text) return { op: { ...base, state: "already-applied", detail: "Already has the suggested lines." } };
6971
- return { op: { ...base, state: "apply-merged", detail: "The file changed since: merged with the suggested lines, no conflict." }, content: merged.text };
6972
- }
6973
- async function planSuggestionApply(input) {
6974
- const { parsed, config, workspace: ws } = input;
6975
- const payload = parsed.payload;
6976
- const notes = [];
6977
- const out = (outcome, message, rest2 = {}) => ({
6978
- outcome,
6979
- exitCode: SUGGESTION_APPLY_EXIT[outcome],
6980
- message,
6981
- payloadSha: parsed.sha,
6982
- payload,
6983
- gate: null,
6984
- ops: [],
6985
- writes: [],
6986
- checks: null,
6987
- problems: [],
6988
- notes,
6989
- ...payload.componentSpec ? { brief: payload.componentSpec.brief } : {},
6990
- ...rest2
6991
- });
6992
- const problems = suggestionPathProblems(payload, config);
6993
- if (payload.checks.blocked) problems.push(`It was suggested with a failing check (${payload.checks.blocked}): ask the author to fix it and send it again.`);
6994
- for (const f of payload.files ?? []) {
6995
- if (f.afterSha !== null && typeof f.content !== "string") problems.push(`"${f.path}" has no content in the suggestion: it is incomplete, ask the author to send it again.`);
6996
- else if (f.afterSha !== null && gitBlobSha(f.content) !== f.afterSha) problems.push(`"${f.path}" does not match the content it was suggested with: someone edited it by hand.`);
6997
- }
6998
- if (problems.length) return out("refused", "Not applied: the suggestion asks for something Bridge never writes.", { problems: [...new Set(problems)] });
6999
- if (parsed.edited && !input.acceptEdited) {
7000
- return out("refused", 'Not applied: the suggestion was edited by hand since Bridge wrote it. Read the change, then apply it with "accept edited" if it is right.', {
7001
- problems: ["The payload no longer matches the sha Bridge recorded."]
7002
- });
7003
- }
7004
- const root = tokenRoot(config);
7005
- const tokenFiles = selectTokenFiles(config, await ws.list());
7006
- const targets = [
7007
- ...(payload.files ?? []).map((f) => f.path),
7008
- ...(payload.docs ?? []).map((d) => d.path),
7009
- ...payload.componentSpec ? [payload.componentSpec.specPath] : [],
7010
- ...payload.tokens?.edits.length ? tokenFiles.map((f) => root ? `${root}/${f}` : f) : []
7011
- ];
7012
- if (ws.isSymlink) {
7013
- const links = [];
7014
- for (const p of new Set(targets)) if (await ws.isSymlink(p)) links.push(`"${p}" is a symbolic link (or inside one): Bridge never writes through links.`);
7015
- if (links.length) return out("refused", "Not applied: the suggestion writes through a symbolic link.", { problems: links });
7016
- }
7017
- const governance = input.governance ?? { owners: config.governance.owners, rights: config.governance.rights ?? null, soloOwnerCanApproveBoth: config.governance.soloOwnerCanApproveBoth };
7018
- const approvals = summarizeSuggestionApprovals(input.comments, governance, { payloadSha: parsed.sha, author: input.author });
7019
- const review = suggestionReviewState(payload.tier.tier, approvals);
7020
- const missing = pendingApprovals(payload.tier.tier, approvals);
7021
- const approved = review === "approved";
7022
- const gate = { review, missing, approvals, forced: !approved && Boolean(input.forceUnapproved), edited: parsed.edited };
7023
- const ops = [];
7024
- const writes = /* @__PURE__ */ new Map();
7025
- const current = /* @__PURE__ */ new Map();
7026
- for (const f of tokenFiles) {
7027
- const r = await ws.read(root ? `${root}/${f}` : f);
7028
- if (r) current.set(f, r.text);
7029
- }
7030
- const before = new LayeredTokenSet(config, current);
7031
- let set = before;
7032
- const appliedEdits = [];
7033
- for (const e of payload.tokens?.edits ?? []) {
7034
- const r = planTokenEdit(set, e, payload.tokens.changes);
7035
- ops.push(r.op);
7036
- notes.push(...r.notes);
7037
- if (r.op.state === "apply" && r.files) {
7038
- set = new LayeredTokenSet(config, r.files);
7039
- appliedEdits.push(e);
7040
- }
7041
- }
7042
- for (const [f, text] of set.files) if (text !== before.files.get(f)) writes.set(set.repoPath(f), text);
7043
- for (const f of payload.files ?? []) {
7044
- if (writes.has(f.path)) {
7045
- notes.push(`${f.path}: the token edits write this file, its copy in the suggestion is not used.`);
7046
- continue;
7047
- }
7048
- const r = await planFile(ws, payload.base.sha, f);
7049
- ops.push(r.op);
7050
- if (r.op.state === "apply" || r.op.state === "apply-merged") writes.set(f.path, r.content ?? null);
7051
- }
7052
- for (const d of payload.docs ?? []) {
7053
- const base = { type: "docs", path: d.path };
7054
- const docs = d.after;
7055
- let text;
7056
- try {
7057
- const issues = validateDocs(docs);
7058
- if (issues.length) {
7059
- problems.push(`${d.path}: ${issues.map((i) => i.message).join(" ")}`);
7060
- continue;
7061
- }
7062
- const cur = await ws.read(d.path);
7063
- text = docsFileText(docs, cur?.text ?? null);
7064
- if (cur && text === cur.text) {
7065
- ops.push({ ...base, state: "already-applied", detail: "The docs already say this." });
7066
- continue;
7067
- }
7068
- ops.push({ ...base, state: "apply", detail: cur ? "Update the changed fields; other fields stay as they are now." : "Create the docs file." });
7069
- writes.set(d.path, text);
7070
- } catch (err) {
7071
- problems.push(`${d.path}: the suggested docs are not a docs.json Bridge can read (${err instanceof Error ? err.message : String(err)}).`);
7072
- }
7073
- }
7074
- if (payload.componentSpec) {
7075
- const s = payload.componentSpec;
7076
- const text = `${JSON.stringify(s.spec, null, 2)}
7077
- `;
7078
- const cur = await ws.read(s.specPath);
7079
- if (cur && cur.text === text) ops.push({ type: "component-spec", path: s.specPath, state: "already-applied", detail: "The spec file is already there." });
7080
- else {
7081
- ops.push({ type: "component-spec", path: s.specPath, state: "apply", detail: `Write the spec; the ${s.component} code itself is written from the brief.` });
7082
- writes.set(s.specPath, text);
7083
- }
7084
- }
7085
- const afterFiles = new Map(set.files);
7086
- for (const [p, content] of writes) {
7087
- const tf = tokenFileOf(config, p);
7088
- if (!tf) continue;
7089
- if (content === null) afterFiles.delete(tf);
7090
- else afterFiles.set(tf, content);
7091
- }
7092
- const after = new LayeredTokenSet(config, afterFiles);
7093
- const tokenChanges = diffLayered(before, after);
7094
- const issueKey4 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
7095
- const prevErrors = new Set(validateLayered(before).map(issueKey4));
7096
- const newErrors = tokenChanges.length ? validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey4(i))) : [];
7097
- let pairs = [];
7098
- if (config.contrast && tokenChanges.length) {
7099
- try {
7100
- pairs = parseContrastChecks((await ws.read(config.contrast.path))?.text);
7101
- } catch {
7102
- notes.push(`${config.contrast.path} does not parse: contrast was not checked.`);
7103
- }
7104
- }
7072
+ // ../core/src/token-proposal.ts
7073
+ var issueKey2 = (i) => `${i.code}|${i.path ?? ""}|${i.file ?? ""}|${i.message}`;
7074
+ function planTokenProposal(input) {
7075
+ const { before, config } = input;
7076
+ const r = applyTokenEdits(before, input.edits);
7077
+ const after = new LayeredTokenSet(before.config, r.files);
7078
+ const changes = diffLayered(before, after);
7079
+ if (!changes.length) return null;
7080
+ const prevErrors = new Set(validateLayered(before).map(issueKey2));
7081
+ const newErrors = validateLayered(after).filter((i) => i.level === "error" && !prevErrors.has(issueKey2(i)));
7105
7082
  const modes = config.contrast?.modes;
7106
- const contrast = pairs.length ? contrastDelta(runLayeredContrast(before, pairs, modes), runLayeredContrast(after, pairs, modes)) : [];
7083
+ const contrast = input.pairs.length ? contrastDelta(runLayeredContrast(before, input.pairs, modes), runLayeredContrast(after, input.pairs, modes)) : [];
7107
7084
  const newFailures = contrast.filter((d) => d.passBefore && !d.passAfter);
7108
- const otherFiles = [...writes.keys()].filter((p) => !tokenFileOf(config, p));
7109
- const tier = classifyRisk({ tokenChanges: tokenChanges.map((c) => ({ kind: c.kind, path: c.path, ...c.kind === "changed" ? { descriptionOnly: c.fields.length > 0 && c.fields.every((x) => x === "description") } : {} })), files: otherFiles.map((path) => ({ path })), config });
7110
- const tierIncreased = writes.size > 0 && tier.tier > payload.tier.tier;
7111
- const checks = { tierWhenSuggested: payload.tier.tier, tier, tierIncreased, newErrors, newFailures, contrast, tokenChanges };
7112
- const rest = { gate, ops, checks, problems };
7113
- if (problems.length) return out("refused", "Not applied: part of the suggestion is not valid.", rest);
7114
- const conflicts = ops.filter((o) => o.state === "conflict");
7115
- if (conflicts.length) {
7116
- return out("conflict", `Not applied: ${conflicts.length} change(s) conflict with the code now. Ask the author to refresh the suggestion on today's code, or apply those parts by hand.`, rest);
7117
- }
7118
- if (!writes.size) return out("already-applied", "Already applied: the code has every change of this suggestion.", rest);
7119
- if (newErrors.length || newFailures.length) {
7120
- const why = [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ");
7121
- return out("blocked", `Not applied: on today's code it would add ${why}. Ask the author to refresh the suggestion.`, rest);
7122
- }
7123
- if (tierIncreased && !input.forceUnapproved) {
7124
- return out("blocked", `Not applied: on today's code it is a tier ${tier.tier} change, it was approved as tier ${payload.tier.tier}. It needs a new approval.`, rest);
7085
+ const impact = layeredImpact(before, after, changes);
7086
+ const tier = classifyLayered(changes, [], config);
7087
+ const summary2 = input.summary?.trim() || input.title;
7088
+ const body = [composeProposalBody({ summary: summary2, changes, contrast, impact, tier, validationErrors: newErrors.length, tool: input.tool ?? "Bridge MCP" }), ...input.bodyExtra ?? []].filter(Boolean).join("\n\n");
7089
+ const slug2 = slugify(input.title, 48) || "change";
7090
+ const changesetPath = `${config.changesets?.dir ?? ".changeset"}/bridge-${slug2}.md`;
7091
+ const touched = r.touched.map((f) => after.repoPath(f));
7092
+ const files = [
7093
+ ...r.touched.map((f) => ({ path: after.repoPath(f), content: after.files.get(f) })),
7094
+ ...config.changesets ? [{ path: changesetPath, content: formatChangeset(config.changesets.package, tier.bump, summary2) }] : []
7095
+ ];
7096
+ const blocked = newErrors.length || newFailures.length ? [newErrors.length ? `${newErrors.length} new validation error(s)` : "", newFailures.length ? `${newFailures.length} contrast pair(s) would fail` : ""].filter(Boolean).join(" and ") : null;
7097
+ return { title: input.title, summary: summary2, edits: input.edits, before, after, changes, tier, contrast, newFailures, newErrors, impact, notes: r.notes, touched, files, body, slug: slug2, blocked };
7098
+ }
7099
+ async function writeTokenProposalOnGitHub(gh, plan, opts) {
7100
+ if (plan.blocked) throw new Error(`Not opened: ${plan.blocked}. Bridge never opens a branch that breaks the gate.`);
7101
+ const jira = opts.jira ?? null;
7102
+ let branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}`, jira);
7103
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = branchWithJiraKey(`${opts.branchPrefix}${plan.slug}-${i}`, jira);
7104
+ const commit = await gh.commitToNewBranch({ base: opts.base, branch, message: `feat(tokens): ${plan.title}
7105
+
7106
+ Proposed through ${opts.tool ?? "Bridge MCP"}. Tier ${plan.tier.tier}.`, files: plan.files });
7107
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
7108
+ const draft = plan.tier.tier >= 2;
7109
+ const pr = await gh.createPullRequest({ base: opts.base, head: branch, title: titleWithJiraKey(`feat(tokens): ${plan.title}`, jira), body: plan.body, draft });
7110
+ await gh.addLabels(pr.number, labels);
7111
+ return { branch, commit: commit.sha, pr: pr.number, url: pr.url, labels, draft };
7112
+ }
7113
+ var tokenLabels = (plan, before = [], after = []) => [
7114
+ .../* @__PURE__ */ new Set([...before, `tier: ${plan.tier.tier}`, ...plan.tier.tier === 3 ? ["tokens"] : [], ...plan.tier.bump === "major" ? ["breaking"] : [], ...after])
7115
+ ];
7116
+ function tokenSuggestionPayload(plan, ctx) {
7117
+ const tokenFiles = new Set(plan.touched);
7118
+ const files = plan.files.filter((f) => !tokenFiles.has(f.path)).map((f) => ({ path: f.path, beforeSha: null, afterSha: f.content === null ? null : gitBlobSha(f.content), content: f.content }));
7119
+ return {
7120
+ schema: SUGGESTION_SCHEMA,
7121
+ kind: "token-change",
7122
+ title: plan.title,
7123
+ summary: plan.summary,
7124
+ source: ctx.source,
7125
+ ...ctx.suggestedBy ? { suggestedBy: ctx.suggestedBy.replace(/^@/, "") } : {},
7126
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7127
+ base: ctx.base,
7128
+ tier: { tier: plan.tier.tier, bump: plan.tier.bump },
7129
+ tokens: {
7130
+ edits: plan.edits,
7131
+ changes: plan.changes.map((c) => ({ kind: c.kind, path: c.path, from: c.from, file: c.file, before: c.before, after: c.after, beforeCss: c.beforeCss, afterCss: c.afterCss }))
7132
+ },
7133
+ ...files.length ? { files } : {},
7134
+ checks: {
7135
+ newErrors: plan.newErrors.length,
7136
+ contrast: plan.contrast.map((d) => ({ pair: `${d.fg} on ${d.bg}`, mode: d.modes || void 0, before: d.before ?? 0, after: d.after ?? 0, min: d.min, pass: d.passAfter })),
7137
+ blocked: plan.blocked,
7138
+ impact: plan.impact.total
7139
+ },
7140
+ ...ctx.links ? { links: ctx.links } : {},
7141
+ ...ctx.previews?.length ? { previews: ctx.previews } : {}
7142
+ };
7143
+ }
7144
+ async function suggestTokenProposalOnGitHub(gh, plan, opts) {
7145
+ if (plan.blocked) throw new Error(`Not suggested: ${plan.blocked}. Bridge never suggests a change that breaks the gate.`);
7146
+ const sha = opts.baseSha ?? await gh.getBranchSha(opts.base);
7147
+ if (!sha) throw new Error(`Branch ${opts.base} not found. Check the default branch in bridge.config.json.`);
7148
+ const links = opts.neededBy || opts.links ? { ...opts.links, ...opts.neededBy ? { neededBy: opts.neededBy } : {} } : void 0;
7149
+ const payload = tokenSuggestionPayload(plan, { base: { ref: opts.base, sha }, source: opts.source, suggestedBy: opts.suggestedBy, links, previews: opts.previews });
7150
+ const labels = tokenLabels(plan, opts.labels, opts.labelsAfter);
7151
+ const created = await createSuggestionOnGitHub(gh, payload, { labels, assignees: opts.assignees, details: plan.body });
7152
+ return { status: "suggested", number: created.number, url: created.url, sha: created.sha, labels, warnings: created.warnings };
7153
+ }
7154
+
7155
+ // ../core/src/figma-push-github.ts
7156
+ var pushSource = (d) => ["figma", "studio", "cli", "mcp"].includes(d.source) ? d.source : "figma";
7157
+ var specTier = (d) => ({ tier: 2, bump: d.kind === "new-component" || d.kind === "new-variant" || d.kind === "prop-change" ? "minor" : "patch" });
7158
+ function componentSpecSuggestionPayload(d, plan, ctx) {
7159
+ const specPath = figmaSpecPath(d);
7160
+ const spec = JSON.parse(formatFigmaSpec(d, plan));
7161
+ const brief = figmaDraftBrief({ ...d, tokenEdits: plan.edits, reasons: plan.reasons }, { suggestion: true, specPath, componentsDir: ctx.componentsDir });
7162
+ const by = (ctx.suggestedBy ?? d.requestedBy)?.replace(/^@/, "");
7163
+ const figma = d.figma?.fileKey || d.figma?.nodeId || d.figma?.url ? { ...d.figma.fileKey ? { fileKey: d.figma.fileKey } : {}, ...d.figma.nodeId ? { nodeId: d.figma.nodeId } : {}, ...d.figma.url ? { url: d.figma.url } : {}, ...figmaNodes(d) } : void 0;
7164
+ const previews = (d.images ?? []).map((url, i) => ({ label: `${d.component} in Figma${(d.images?.length ?? 0) > 1 ? ` ${i + 1}` : ""}`, url }));
7165
+ return {
7166
+ schema: SUGGESTION_SCHEMA,
7167
+ kind: "component-spec",
7168
+ title: d.title,
7169
+ summary: d.summary.trim() || d.title,
7170
+ source: pushSource(d),
7171
+ ...by ? { suggestedBy: by } : {},
7172
+ createdAt: ctx.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
7173
+ base: ctx.base,
7174
+ tier: specTier(d),
7175
+ componentSpec: { component: d.component, specPath, spec, brief },
7176
+ checks: { newErrors: 0, contrast: [], blocked: null, impact: 0 },
7177
+ ...figma ? { links: { figma } } : {},
7178
+ ...previews.length ? { previews } : {}
7179
+ };
7180
+ }
7181
+ function figmaNodes(d) {
7182
+ const f = d.figma;
7183
+ if (!f) return {};
7184
+ const url = (id, given) => given ?? (f.fileKey ? figmaNodeUrl(f.fileKey, id) : void 0);
7185
+ const nodes = [];
7186
+ if (f.before?.nodeId) nodes.push({ nodeId: f.before.nodeId, state: "before", ...url(f.before.nodeId, f.before.url) ? { url: url(f.before.nodeId, f.before.url) } : {}, ...f.before.nodeName ? { name: f.before.nodeName } : {} });
7187
+ if (f.nodeId) nodes.push({ nodeId: f.nodeId, state: "after", ...url(f.nodeId, f.url) ? { url: url(f.nodeId, f.url) } : {}, ...f.nodeName ? { name: f.nodeName } : {} });
7188
+ return nodes.length ? { nodes } : {};
7189
+ }
7190
+ async function openComponentPushOnGitHub(gh, input) {
7191
+ const d = input.diff;
7192
+ if (!input.plan && !input.tokens) throw new Error("Route the change first: pass the token facts or the plan.");
7193
+ const plan = input.plan ?? planComponentPush(d, input.tokens);
7194
+ if (plan.route === "manual") return { status: "not-sent", plan, link: null };
7195
+ if (input.dryRun) return { status: "dry-run", plan, link: null };
7196
+ const mode = input.mode ?? "direct";
7197
+ if (!gh.canWrite) throw new GitHubError(401, mode === "suggest" ? "Suggesting a component change needs a GitHub token (issues: write)." : "Sending a component change needs a GitHub token (contents and pull requests: write).");
7198
+ const origin = {
7199
+ component: d.component,
7200
+ figmaName: d.figmaName,
7201
+ route: plan.route,
7202
+ kind: d.kind,
7203
+ ...d.figma?.nodeId ? { nodeId: d.figma.nodeId } : {},
7204
+ ...d.figma?.fileKey ? { fileKey: d.figma.fileKey } : {},
7205
+ ...d.figma?.url ? { url: d.figma.url } : {},
7206
+ ...d.requestedBy ? { requestedBy: d.requestedBy } : {}
7207
+ };
7208
+ if (plan.route === "token-change") {
7209
+ if (!input.writeTokens) throw new GitHubError(422, "This change is a token change: send it where the token files are read (the DS Bridge plugin, bridge_push_figma_component or `bridge figma push`).");
7210
+ const figma = { ...origin.fileKey ? { fileKey: origin.fileKey } : {}, ...origin.nodeId ? { nodeId: origin.nodeId } : {}, ...origin.url ? { url: origin.url } : {}, ...figmaNodes(d) };
7211
+ const r = await input.writeTokens({ edits: plan.edits, title: figmaBranchTitle(d), summary: d.summary.trim() || d.title, bodyExtra: [figmaOriginSection(origin)], mode, figma });
7212
+ await gh.addLabels(r.number, [FROM_FIGMA_LABEL]);
7213
+ if (r.status === "suggested") return { status: "suggested", plan, link: null, suggestion: { route: "token-change", number: r.number, url: r.url } };
7214
+ return { status: "opened", plan, link: { route: "token-change", number: r.number, url: r.url, branch: r.branch } };
7125
7215
  }
7126
- if (!approved && !input.forceUnapproved) {
7127
- const who = review === "changes-requested" ? `changes requested by ${approvals.changesRequested.map((l) => `@${l}`).join(", ")}` : `missing ${missing.map((m) => m === "approval" ? "an approval" : `${m} approval`).join(" and ")}`;
7128
- return out("not-approved", `Not applied: it is not approved yet (${who}). Ask the owners to review it on the issue.`, rest);
7216
+ if (mode === "suggest") {
7217
+ const sha = input.baseSha ?? await gh.getBranchSha(input.base);
7218
+ if (!sha) throw new GitHubError(404, `Branch ${input.base} not found. Check the default branch in bridge.config.json.`);
7219
+ const payload = componentSpecSuggestionPayload(d, plan, { base: { ref: input.base, sha }, suggestedBy: input.suggestedBy });
7220
+ const created = await createSuggestionOnGitHub(gh, payload, { labels: [...input.labels ?? [], WAITING_FOR_CLAUDE_LABEL] });
7221
+ return { status: "suggested", plan, link: null, suggestion: { route: "claude-draft", number: created.number, url: created.url } };
7129
7222
  }
7130
- const forced = gate.forced || tierIncreased && Boolean(input.forceUnapproved);
7131
- return out("ready", `Ready: ${writes.size} file(s) to write${forced ? ", without every approval" : ""}.`, {
7132
- ...rest,
7133
- gate: { ...gate, forced },
7134
- writes: [...writes].map(([path, content]) => ({ path, content })).sort((a, b) => a.path.localeCompare(b.path))
7223
+ const spec = figmaSpecPath(d);
7224
+ const o = { ...origin, spec, status: "spec" };
7225
+ let branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}`;
7226
+ for (let i = 2; await gh.getBranchSha(branch).then(Boolean, () => false); i++) branch = `${input.branchPrefix}figma-${figmaSpecSlug(d)}-${i}`;
7227
+ await gh.commitToNewBranch({
7228
+ base: input.base,
7229
+ branch,
7230
+ message: `chore(figma): spec for ${figmaBranchTitle(d)}
7231
+
7232
+ Sent from Figma with ${input.tool ?? "the DS Bridge Figma plugin"}. Claude AI drafts the code on this branch; a person reviews it.`,
7233
+ files: [{ path: spec, content: formatFigmaSpec(d, plan) }]
7135
7234
  });
7235
+ const pr = await gh.createPullRequest({ base: input.base, head: branch, title: figmaBranchTitle(d), body: figmaDraftBody(d, plan, o), draft: true });
7236
+ await gh.addLabels(pr.number, [.../* @__PURE__ */ new Set([...input.labels ?? [], FROM_FIGMA_LABEL, REQUEST_LABEL, WAITING_FOR_CLAUDE_LABEL])]);
7237
+ return { status: "opened", plan, link: { route: "claude-draft", number: pr.number, url: pr.url, branch } };
7136
7238
  }
7137
- function suggestionAppliedComment(plan, where2) {
7138
- return composeSuggestionAppliedComment({ ...where2, payloadSha: plan.payloadSha, ...plan.gate?.forced ? { forced: true } : {} });
7239
+ async function listFigmaChangesOnGitHub(gh, opts = {}) {
7240
+ const pulls = await gh.listPullRequests(opts.state ?? "open");
7241
+ const out = [];
7242
+ for (const p of pulls) {
7243
+ if (!p.labels.includes(FROM_FIGMA_LABEL)) continue;
7244
+ const origin = parseFigmaOrigin(p.body);
7245
+ if (!origin) continue;
7246
+ out.push({ number: p.number, title: p.title, url: p.url, branch: p.headRef, origin, status: figmaPushStatus({ state: p.state, draft: p.draft, labels: p.labels, route: origin.route }) });
7247
+ }
7248
+ return out;
7139
7249
  }
7140
- function suggestionApplyTable(plan) {
7141
- const label = { "already-applied": "already applied", apply: "apply", "apply-merged": "apply (merged)", conflict: "**conflict**" };
7142
- const cell4 = (s) => s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
7143
- return ["| Change | Result | Detail |", "| --- | --- | --- |", ...plan.ops.map((o) => `| \`${cell4(o.path)}\` | ${label[o.state]} | ${cell4(o.detail)} |`)].join("\n");
7250
+ async function figmaChangeOnGitHub(gh, n, opts = {}) {
7251
+ const pull = await gh.getPullRequest(n);
7252
+ const origin = parseFigmaOrigin(pull.body);
7253
+ if (!origin) throw new GitHubError(404, `#${n} was not sent from Figma (no "From Figma" block in its description).`);
7254
+ const status = figmaPushStatus({ state: pull.state, draft: pull.draft, labels: pull.labels, route: origin.route });
7255
+ if (origin.route === "token-change") return { pull, origin, status, spec: void 0, brief: void 0 };
7256
+ const text = origin.spec ? await gh.getFileText(origin.spec, pull.headSha).catch(() => void 0) : void 0;
7257
+ const spec = parseFigmaSpec(text);
7258
+ if (!spec) throw new GitHubError(404, `The spec ${origin.spec ?? FIGMA_SPEC_DIR} is missing from ${pull.headRef}. Send the component from Figma again.`);
7259
+ const brief = figmaDraftBrief(spec, { pr: n, branch: pull.headRef, specPath: origin.spec, componentsDir: opts.componentsDir });
7260
+ return { pull, origin, status, spec, brief };
7261
+ }
7262
+ async function markFigmaChangeDraftedOnGitHub(gh, n) {
7263
+ if (!gh.canWrite) throw new GitHubError(401, "Marking a branch drafted needs a GitHub token (pull requests: write).");
7264
+ const pull = await gh.getPullRequest(n);
7265
+ const origin = parseFigmaOrigin(pull.body);
7266
+ if (!origin || origin.route !== "claude-draft") throw new GitHubError(422, `#${n} is not a Claude draft branch from Figma.`);
7267
+ if (pull.state !== "open") throw new GitHubError(422, `#${n} is ${pull.state}: only an open branch is marked drafted.`);
7268
+ const files = (await gh.listPullRequestFiles(n)).map((f) => f.filename).filter((f) => !f.startsWith(`${FIGMA_SPEC_DIR}/`));
7269
+ if (!files.length) throw new GitHubError(422, `${pull.headRef} only carries the spec: write the component code there first, push it, then mark it drafted.`);
7270
+ await gh.updatePullRequest(n, { body: withFigmaOrigin(pull.body, { ...origin, status: "drafted" }) });
7271
+ await gh.removeLabel(n, WAITING_FOR_CLAUDE_LABEL).catch(() => void 0);
7272
+ await gh.addLabels(n, [DRAFTED_BY_CLAUDE_LABEL]);
7273
+ const labels = [...pull.labels.filter((l) => l !== WAITING_FOR_CLAUDE_LABEL), DRAFTED_BY_CLAUDE_LABEL];
7274
+ return { status: figmaPushStatus({ state: pull.state, labels, route: origin.route }), files };
7144
7275
  }
7145
7276
 
7146
7277
  // ../core/src/node/git-history.ts
@@ -10424,21 +10555,7 @@ async function branchFacts(ctx, gh, n, viewer) {
10424
10555
  const product = productOfBranch(productsOf(config), { files: files.map((f) => f.filename), labels: pr.labels });
10425
10556
  return { config, pr, files, ci, approvals, tier, gate, status, fresh, compare, exploration, product, lifecycle: pullState(pullLike), write: repo ? hasWriteAccess(repo.permissions) : null, repo, solo: who.solo };
10426
10557
  }
10427
- var STATUS_WORDS = {
10428
- merged: "Merged",
10429
- closed: "Closed",
10430
- draft: "Draft",
10431
- "ci-failing": "Blocked by tests",
10432
- "changes-requested": "Changes requested",
10433
- "ci-running": "Tests running",
10434
- "needs-design": "Needs design approval",
10435
- "needs-tech": "Needs tech approval",
10436
- "needs-approval": "Needs approval",
10437
- conflict: "Conflict with main",
10438
- checking: "Checking",
10439
- ready: "Ready to merge",
10440
- unknown: "Status unknown"
10441
- };
10558
+ var STATUS_WORDS = REVIEW_STATUS_LABEL;
10442
10559
  async function branchStatus(ctx, a) {
10443
10560
  const n = branchNumber(a.pr);
10444
10561
  const gh = await reader(ctx);
@@ -10454,7 +10571,7 @@ async function branchStatus(ctx, a) {
10454
10571
  status: { kind: f.status, label: STATUS_WORDS[f.status] },
10455
10572
  approvalNeeded: f.tier === 1 ? "One approval" : "Design and tech approval",
10456
10573
  approvals: { approvedBy: f.approvals.approvers, changesRequestedBy: f.approvals.changesRequested, design: f.approvals.design, tech: f.approvals.tech, ...f.solo && (f.tier ?? 3) > 1 ? { soloOwner: soloOwnerSentence(f.solo, Boolean(viewer && viewer.toLowerCase() === f.solo)) } : {} },
10457
- tests: { state: f.ci.state, failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
10574
+ tests: { state: f.ci.state, label: CI_STATE_LABEL[f.ci.state], failing: f.ci.failed.map((x) => x.name), running: f.ci.pending.map((x) => x.name) },
10458
10575
  main: { state: f.fresh.state, behindBy: f.fresh.behindBy },
10459
10576
  merge: {
10460
10577
  allowed: f.gate.allowed,
@@ -10502,6 +10619,7 @@ async function whatNeedsMe(ctx, a = {}) {
10502
10619
  else if (f.status === "conflict") needsYou = "Your branch conflicts with main";
10503
10620
  else if (f.status === "ready" && f.gate.canShip) needsYou = "Your branch is ready to merge";
10504
10621
  }
10622
+ if (viewer && !needsYou && f.gate.overridable && !reviewersToAsk({ tier: f.tier, approvals: f.approvals, owners: who.lists, author: p.author, viewer }).length) needsYou = "Only the owner override can merge it";
10505
10623
  return { pr: p.number, title: p.title, url: p.url, author: p.author, product: f.product.name, status: f.status, statusLabel: STATUS_WORDS[f.status], yours: mine, needsYou };
10506
10624
  })
10507
10625
  );
@@ -10673,6 +10791,7 @@ async function listComments(ctx, a) {
10673
10791
  howToReply: "bridge_comment with replyTo: the thread id."
10674
10792
  };
10675
10793
  }
10794
+ var appliedLine = (ns) => ns.length ? ` ${ns.length === 1 ? "Suggestion" : "Suggestions"} ${ns.map((x) => `#${x}`).join(", ")} ${ns.length === 1 ? "is" : "are"} marked applied.` : "";
10676
10795
  async function mergeBranch(ctx, a) {
10677
10796
  const n = branchNumber(a.pr);
10678
10797
  const gh = await writer(ctx, "Merging a branch", RIGHTS.merge);
@@ -10684,7 +10803,8 @@ async function mergeBranch(ctx, a) {
10684
10803
  if (f.gate.allowed) {
10685
10804
  if (a.dryRun) return { pr: n, status: "would-merge", said: "Every rule passes: it can be merged." };
10686
10805
  const r = await mergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, method: "squash" });
10687
- return { pr: n, status: "merged", sha: r.sha, said: "Merged. It goes out with the next library release." };
10806
+ const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
10807
+ return { pr: n, status: "merged", sha: r.sha, said: `Merged. It goes out with the next library release.${appliedLine(s.applied)}`, ...s.warnings.length ? { warnings: s.warnings } : {} };
10688
10808
  }
10689
10809
  if (!reason) {
10690
10810
  const hint = f.gate.overridable ? ` Only approvals are missing and @${viewer} may use the owner override: pass overrideReason (at least ${policy.minReasonLength} characters), it is posted on the branch.` : "";
@@ -10698,7 +10818,8 @@ async function mergeBranch(ctx, a) {
10698
10818
  const method = allowedMergeMethods(f.repo)[0] ?? "squash";
10699
10819
  try {
10700
10820
  const r = await overrideMergeOnGitHub(gh, { number: n, headSha: f.pr.headSha, login: viewer, reason, missing: f.gate.missing, method });
10701
- return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.` };
10821
+ const s = await markSuggestionsAppliedOnGitHub(gh, f.pr);
10822
+ return { pr: n, status: "merged", override: true, sha: r.sha, reasonUrl: r.commentUrl, said: `Merged as owner override. The reason is on #${n}.${appliedLine(s.applied)}`, ...s.warnings.length ? { warnings: s.warnings } : {} };
10702
10823
  } catch (err) {
10703
10824
  if (err instanceof OverrideMergeError) throw new Error(err.message);
10704
10825
  throw err;
@@ -11067,11 +11188,33 @@ async function suggestionGovernance(ctx, gh) {
11067
11188
  return { config, gov: { owners, rights: rights ?? null, ...teams ? { teams } : {}, ...config.governance.soloOwnerCanApproveBoth ? { soloOwnerCanApproveBoth: true } : {} } };
11068
11189
  }
11069
11190
  var OPEN_STATUSES = ["needs-review", "changes-requested", "approved", "applying", "outdated"];
11191
+ var REVIEW_STATUSES = ["needs-review", "changes-requested", "approved"];
11192
+ var MAX_STALENESS_READS = 30;
11193
+ var mainRef = (ctx, config) => ctx.ref ?? baseBranchOf(config);
11194
+ async function stalenessOf(ctx, gh, d, config, gov) {
11195
+ if (d.issue.state !== "open" || !REVIEW_STATUSES.includes(d.status)) return null;
11196
+ return suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
11197
+ }
11070
11198
  async function listSuggestionsOp(ctx, a = {}) {
11071
11199
  const gh = await reader(ctx);
11072
11200
  const status = a.status ?? "all";
11073
11201
  const state = a.state ?? (status === "all" ? "open" : OPEN_STATUSES.includes(status) ? "open" : "closed");
11074
- let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100 });
11202
+ const pulls = await gh.listPullRequests("all").catch(() => []);
11203
+ let rows = await listSuggestionsOnGitHub(gh, { state, perPage: 100, pulls });
11204
+ const open = rows.filter((r) => r.state === "open" && REVIEW_STATUSES.includes(r.status)).slice(0, MAX_STALENESS_READS);
11205
+ if (open.length) {
11206
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11207
+ const outdated = /* @__PURE__ */ new Set();
11208
+ await Promise.all(
11209
+ open.map(async (r) => {
11210
+ const d = await getSuggestionOnGitHub(gh, r.number, gov, { pulls }).catch(() => null);
11211
+ if (!d?.parsed.ok) return;
11212
+ const st = await suggestionStalenessOnGitHub(gh, d, { config, governance: gov, ref: mainRef(ctx, config) }).catch(() => null);
11213
+ if (st?.outdated) outdated.add(r.number);
11214
+ })
11215
+ );
11216
+ rows = rows.map((r) => outdated.has(r.number) ? { ...r, status: stalenessStatus(r.status, { outdated: true }) } : r);
11217
+ }
11075
11218
  if (status !== "all") rows = rows.filter((r) => r.status === status);
11076
11219
  if (a.kind) rows = rows.filter((r) => r.kind === a.kind);
11077
11220
  const total = rows.length;
@@ -11101,8 +11244,10 @@ var roleText = (m) => m === "approval" ? "one approval" : `${m} approval`;
11101
11244
  async function getSuggestionOp(ctx, a) {
11102
11245
  const n = suggestionNumber(a.number);
11103
11246
  const gh = await reader(ctx);
11104
- const { gov } = await suggestionGovernance(ctx, gh);
11247
+ const { config, gov } = await suggestionGovernance(ctx, gh);
11105
11248
  const d = await getSuggestionOnGitHub(gh, n, gov);
11249
+ const stale = d.parsed.ok ? await stalenessOf(ctx, gh, d, config, gov) : null;
11250
+ d.status = stalenessStatus(d.status, stale);
11106
11251
  const base = { number: n, url: d.issue.url, title: d.issue.title, state: d.issue.state, status: d.status, said: SUGGESTION_STATUS_LABEL[d.status], author: d.issue.author, labels: d.issue.labels, linkedPrs: d.linkedPrs };
11107
11252
  if (!d.parsed.ok) {
11108
11253
  if (!d.issue.labels.includes(SUGGESTION_LABEL) && d.parsed.reason === "no-block") throw new Error(`Issue #${n} is not a Bridge suggestion: it has no "${SUGGESTION_LABEL}" label and no suggestion block.`);
@@ -11143,8 +11288,9 @@ async function getSuggestionOp(ctx, a) {
11143
11288
  ...solo && p.tier.tier > 1 ? { soloOwner: soloOwnerSentence(solo) } : {}
11144
11289
  },
11145
11290
  applied: d.applied,
11291
+ ...stale?.outdated ? { outdated: { mainSha: stale.mainSha, conflicts: stale.conflicts.map((c) => ({ path: c.path, detail: c.detail })) } } : {},
11146
11292
  ...p.componentSpec ? { brief: p.componentSpec.brief } : {},
11147
- next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ? "Outdated: the code changed since it was suggested. Ask the author to refresh it." : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
11293
+ next: d.status === "approved" ? `Approved. Apply it: bridge_apply_suggestion { number: ${n}, mode: "dry-run" } first, then mode "branch" (or \`bridge apply ${n}\`). The pull request must say "Closes #${n}".` : d.status === "needs-review" ? `Waiting for ${missing.map(roleText).join(" and ") || "a review"} from the owners in ${CONFIG_FILE}: bridge_review_suggestion { number: ${n}, event: "approve" }.` : d.status === "changes-requested" ? "Changes requested: the author sends a new version (approvals then start again)." : d.status === "applying" ? `Being applied${d.linkedPrs.length ? ` in ${d.linkedPrs.map((x) => `#${x.number}`).join(", ")}` : ""}. It closes when the pull request merges.` : d.status === "outdated" ? `Outdated: ${stale?.conflicts[0] ? stale.conflicts[0].detail.replace(/\.$/, "") : "main changed what it changes since it was suggested"}. Ask the author to send it again on today's code.` : `${SUGGESTION_STATUS_LABEL[d.status]}: nothing to do.`
11148
11294
  };
11149
11295
  }
11150
11296
  async function reviewSuggestionOp(ctx, a) {
@@ -11187,12 +11333,7 @@ async function reviewSuggestionOp(ctx, a) {
11187
11333
  };
11188
11334
  }
11189
11335
  function githubWorkspace(gh, ref) {
11190
- let files;
11191
- return {
11192
- read: async (path) => await gh.getFile(path, ref) ?? null,
11193
- readAt: async (sha, path) => await gh.readFile(path, sha).catch(() => void 0) ?? null,
11194
- list: () => files ??= gh.listFiles(ref)
11195
- };
11336
+ return githubSuggestionWorkspace(gh, ref);
11196
11337
  }
11197
11338
  var MAX_DIFF = 12e3;
11198
11339
  var cut = (s) => s && s.length > MAX_DIFF ? `${s.slice(0, MAX_DIFF)}
@@ -11228,6 +11369,14 @@ async function applySuggestionOp(ctx, a) {
11228
11369
  return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: ${d.parsed.message}` };
11229
11370
  }
11230
11371
  if (d.status === "declined") return { number: n, status: "refused", outcome: "refused", exitCode: 1, message: `Not applied: #${n} was declined. Reopen it on GitHub first if it should go in after all.` };
11372
+ if (d.status === "applied") {
11373
+ const warnings2 = [];
11374
+ if (ctx.canWriteRemote) {
11375
+ const w = await syncSuggestionStateLabel(gh, n, d.issue.labels, "applied").catch((e) => `The status label on #${n} could not be updated (${e.message}).`);
11376
+ if (w) warnings2.push(w);
11377
+ }
11378
+ return { number: n, status: "already-applied", outcome: "already-applied", exitCode: 0, message: `#${n} is already applied and closed: nothing to write.`, ...warnings2.length ? { warnings: warnings2 } : {} };
11379
+ }
11231
11380
  const parsed = d.parsed;
11232
11381
  const dryRun = a.dryRun || a.mode === "dry-run";
11233
11382
  const mode = a.mode && a.mode !== "dry-run" ? a.mode : "branch";
@@ -11297,15 +11446,18 @@ async function linkSuggestionOp(ctx, a) {
11297
11446
  if (!d.parsed.ok) throw new Error(`#${n} cannot be linked: ${d.parsed.message}`);
11298
11447
  const warnings = [];
11299
11448
  let branch = a.branch;
11449
+ let merged = false;
11300
11450
  if (a.pr) {
11301
11451
  const pr = await gh.getPullRequest(a.pr);
11302
11452
  branch ??= pr.headRef;
11303
- if (!closingIssueNumbers(pr.body).includes(n)) warnings.push(`#${a.pr} does not say "Closes #${n}": add it to the pull request description, so the suggestion closes when it merges.`);
11453
+ merged = pr.state === "merged";
11454
+ if (!merged && !closingIssueNumbers(pr.body).includes(n)) warnings.push(`#${a.pr} does not say "Closes #${n}": add it to the pull request description, so the suggestion closes when it merges.`);
11304
11455
  }
11305
11456
  const viewer = await viewerOf(ctx, gh);
11306
- const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced });
11457
+ const r = await linkSuggestionPrOnGitHub(gh, n, { pr: a.pr, branch, commit: a.commit, by: viewer ?? void 0, payloadSha: d.parsed.sha, forced: a.forced, merged });
11307
11458
  warnings.push(...r.warnings);
11308
- return { number: n, status: "applying", said: SUGGESTION_STATUS_LABEL.applying, ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: "It closes when the pull request merges." };
11459
+ const status = r.status;
11460
+ return { number: n, status, said: SUGGESTION_STATUS_LABEL[status], ...a.pr ? { pr: a.pr } : {}, ...branch ? { branch } : {}, ...warnings.length ? { warnings } : {}, next: status === "applied" ? "Applied: nothing left to do." : "It closes when the pull request merges." };
11309
11461
  }
11310
11462
  async function createSuggestionOp(ctx, a) {
11311
11463
  const { config } = await ctx.config();
@@ -11935,7 +12087,7 @@ async function visualImpact(ctx, a, run2) {
11935
12087
  if (ctx.mode !== "local" || !ctx.root) throw new Error("Visual impact needs a local checkout (start Bridge with --root): it builds Storybook and the app at both refs.");
11936
12088
  if (!ctx.isGit) throw new Error(`${ctx.root} is not a git repository.`);
11937
12089
  const out = a.out ? resolve3(ctx.root, a.out) : join5(ctx.root, "visual-impact");
11938
- const runImpact = run2 ?? (await import("./src-LKQBSVYE.js")).runImpact;
12090
+ const runImpact = run2 ?? (await import("./src-DRJFSLRV.js")).runImpact;
11939
12091
  const r = await runImpact({
11940
12092
  root: ctx.root,
11941
12093
  base: a.base ?? "main",
@@ -13003,7 +13155,7 @@ async function pushFigmaComponentOp(ctx, a) {
13003
13155
  }
13004
13156
 
13005
13157
  // src/server.ts
13006
- var VERSION = true ? "0.8.3" : "dev";
13158
+ var VERSION = true ? "0.8.5" : "dev";
13007
13159
  var ok = (data) => ({ content: [{ type: "text", text: typeof data === "string" ? data : JSON.stringify(data, null, 2) }] });
13008
13160
  var fail2 = (e) => ({ content: [{ type: "text", text: `Error: ${e.message ?? String(e)}` }], isError: true });
13009
13161
  var run = (fn) => async (a) => {
@@ -13847,7 +13999,7 @@ function createServer(ctx) {
13847
13999
  "bridge_list_suggestions",
13848
14000
  {
13849
14001
  title: "List suggestions",
13850
- description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels; bridge_get_suggestion counts the approvals.",
14002
+ description: "Suggestions in the repo: GitHub issues labeled bridge-suggestion, each an exact change (tokens, docs, a component spec, a brand, a revert, settings) that the owners approve and a developer applies. Each row has the number, title, kind, tier, status (needs-review, changes-requested, approved, applying, applied, declined, outdated) and linked pull requests. Statuses here come from labels, except that an open one whose change no longer applies to main reads outdated; bridge_get_suggestion counts the approvals.",
13851
14003
  inputSchema: {
13852
14004
  status: z.enum(["needs-review", "changes-requested", "approved", "applying", "applied", "declined", "outdated", "all"]).optional().describe("Default all open ones."),
13853
14005
  state: z.enum(["open", "closed", "all"]).optional().describe("Issue state. Default: open, or closed for applied and declined."),