@bridge-ds/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ import { GitHubClient } from './github-client';
2
+ export interface RequestExplorationLink {
3
+ number: number;
4
+ branch: string;
5
+ /** Who is now on it, when someone was assigned in the same step. */
6
+ assignees: string[];
7
+ /** Sentences about the parts that did not go through (label refused): the link itself holds. */
8
+ warnings: string[];
9
+ }
10
+ /**
11
+ * Links a request to an exploration branch, the way `bridge request create --exploration` files one: the hidden block
12
+ * gets `exploration`, the issue gets the `exploration` label, and whoever starts the exploration is assigned, so the
13
+ * request reads "in progress" everywhere. Implementing it then happens on that branch (`request implement --branch`).
14
+ */
15
+ export declare function linkRequestToExplorationOnGitHub(gh: GitHubClient, n: number, branch: string, opts?: {
16
+ assignee?: string | null;
17
+ }): Promise<RequestExplorationLink>;
18
+ /** Assigns someone to a request. Throws when GitHub does not keep the assignee (no access to the repository). */
19
+ export declare function assignRequestOnGitHub(gh: GitHubClient, n: number, login: string): Promise<string[]>;
20
+ /** Declines a request: the reason as a comment the requester is notified of, then the issue closed as not planned. */
21
+ export declare function declineRequestOnGitHub(gh: GitHubClient, n: number, reason: string, by?: string | null): Promise<void>;
@@ -1,4 +1,4 @@
1
- export type RequestKind = 'new-component' | 'new-variant' | 'prop-change' | 'visual' | 'token-binding' | 'bug' | 'other';
1
+ export type RequestKind = 'new-component' | 'new-variant' | 'prop-change' | 'structure' | 'visual' | 'token-binding' | 'bug' | 'other';
2
2
  export type RequestSource = 'figma' | 'studio' | 'cli' | 'mcp';
3
3
  export type RequestStatus = 'requested' | 'in-progress' | 'done' | 'declined';
4
4
  export declare const REQUEST_KINDS: {
@@ -22,6 +22,11 @@ export interface ComponentChangeRequest {
22
22
  title: string;
23
23
  /** What and why, in the requester's words. */
24
24
  summary: string;
25
+ /**
26
+ * What changed for developers in one sentence ("Icon added after the label in primary sm: Pencil, 16px"): written
27
+ * by Claude from the before and after images, or drafted from the detected changes. Optional.
28
+ */
29
+ line?: string | undefined;
25
30
  changes: RequestChange[];
26
31
  /** Where the design lives, when it comes from Figma. */
27
32
  figma?: {
@@ -57,6 +62,16 @@ export declare function composeRequestIssue(r: ComponentChangeRequest, opts?: {
57
62
  }): RequestIssue;
58
63
  /** The request inside an issue body, or undefined when the body has no valid Bridge block. */
59
64
  export declare function parseRequestIssue(body: string | null | undefined): ComponentChangeRequest | undefined;
65
+ /**
66
+ * The issue body with the request linked to an exploration branch: the hidden block gets `exploration` (what the
67
+ * Explorations view, the MCP server and the CLI read) and the readable part says where to implement it. The rest of the
68
+ * body stays as people wrote it. Undefined when the body has no Bridge block (nothing to link).
69
+ */
70
+ export declare function withRequestExploration(body: string | null | undefined, branch: string): string | undefined;
71
+ /** Problems with a decline reason, as sentences that say how to fix them. */
72
+ export declare function declineReasonProblems(reason: string): string[];
73
+ /** The comment posted on a declined request: who declined it and why, in the decliner's words. */
74
+ export declare function declineComment(reason: string, by?: string | null): string;
60
75
  /**
61
76
  * Where a request stands: open with no linked pull request = requested; open with an open linked pull request =
62
77
  * in progress; closed as completed or by a merged pull request = done; closed otherwise = declined.
@@ -0,0 +1,63 @@
1
+ import type { Owners } from './config';
2
+ import { type TeamState, type TeamStates } from './teams';
3
+ /** An owner group from `governance.owners`. */
4
+ export type OwnerGroup = 'design' | 'tech';
5
+ export declare const OWNER_GROUPS: readonly OwnerGroup[];
6
+ /** The three rights, each held by one or more owner groups. */
7
+ export interface Rights {
8
+ approveDesign: OwnerGroup[];
9
+ approveTech: OwnerGroup[];
10
+ ship: OwnerGroup[];
11
+ }
12
+ export type Right = keyof Rights;
13
+ export declare const RIGHTS: readonly Right[];
14
+ /** Plain names of each right, for Settings and the gate messages. "Ship" means publish to the products. */
15
+ export declare const RIGHT_LABEL: Record<Right, string>;
16
+ export declare const DEFAULT_RIGHTS: Rights;
17
+ /** Rights with every missing entry filled from DEFAULT_RIGHTS, groups deduplicated in design, tech order. */
18
+ export declare function normalizeRights(input: Partial<Record<Right, readonly string[]>> | null | undefined): Rights;
19
+ /** Problems in a raw `governance.rights` value, in the words loadConfig reports. Empty: valid. */
20
+ export declare function rightsProblems(raw: unknown): string[];
21
+ /** The entries (people and teams) holding a right: the union of the owner lists of its groups. Empty when those lists are empty. */
22
+ export declare function holdersOf(right: Right, owners: Owners, rights: Rights | null | undefined): string[];
23
+ /**
24
+ * Owner lists to count approvals against (summarizeApprovals): who may approve design, who may approve tech.
25
+ * Without `rights`, the owner lists as they are.
26
+ */
27
+ export declare function approverLists(owners: Owners, rights: Rights | null | undefined, teams?: TeamStates | null): Owners;
28
+ /** approverLists before team expansion: the entries as the config names them, for sentences. */
29
+ export declare function approverEntries(owners: Owners, rights: Rights | null | undefined): Owners;
30
+ /** Who may ship, or null when nobody is singled out (no `rights`, or its groups list no logins): anyone with write access. */
31
+ export interface ShipPolicy {
32
+ /** Logins allowed to ship: people, and team members once read. A team not read keeps its "@org/slug" key. */
33
+ logins: string[];
34
+ /** Owner groups holding the right, for the sentence ("the tech owner", "a design or tech owner"). */
35
+ groups: OwnerGroup[];
36
+ /** The entries as the config names them (people and teams), for the sentence. Absent: `logins`. */
37
+ entries?: string[];
38
+ /** Team states known when the policy was built, for the sentence ("@acme/design (4 people)"). */
39
+ teams?: TeamStates;
40
+ }
41
+ /**
42
+ * Who may ship. With `teams` (TeamDirectory.resolve), team members count; a team that cannot be read still holds
43
+ * the right, so the policy is never dropped to "anyone with write access" because a team was not read.
44
+ */
45
+ export declare function shipPolicy(owners: Owners, rights: Rights | null | undefined, teams?: TeamStates | null): ShipPolicy | null;
46
+ /** Teams in a ship policy that could not be read: the gate names them with the fix. */
47
+ export declare function unreadableShipTeams(policy: ShipPolicy | null | undefined): TeamState[];
48
+ /** Whether `login` may ship. No policy: yes (GitHub write access still decides). */
49
+ export declare function canShip(login: string | null | undefined, policy: ShipPolicy | null | undefined): boolean;
50
+ /** "the tech owner", "the design owner", "a design or tech owner". */
51
+ export declare function groupsPhrase(groups: readonly OwnerGroup[]): string;
52
+ /** The sentence a blocked viewer reads: "Only the tech owner can ship this." */
53
+ /** Teams are named as teams, with their size when known: "Only the tech owner can ship this (@acme/web (4 people))." */
54
+ export declare function whoCanShipSentence(policy: ShipPolicy): string;
55
+ /** Visual before and after: on every pull request that touches tokens or components, or only when asked. */
56
+ export type VisualImpactMode = 'auto' | 'on-demand';
57
+ export interface VisualImpactConfig {
58
+ mode: VisualImpactMode;
59
+ }
60
+ export declare const DEFAULT_VISUAL_IMPACT: VisualImpactConfig;
61
+ /** Label that asks the visual impact workflow to run in on-demand mode. */
62
+ export declare const VISUAL_CHECK_LABEL = "visual-check";
63
+ export declare function visualImpactProblems(raw: unknown): string[];
@@ -0,0 +1,149 @@
1
+ import type { Owners } from './config';
2
+ export type OwnerEntry = {
3
+ kind: 'person';
4
+ login: string;
5
+ } | {
6
+ kind: 'team';
7
+ org: string;
8
+ slug: string;
9
+ };
10
+ /** A person or a team, or null when `raw` is neither. Accepts a leading @ on both. */
11
+ export declare function parseOwnerEntry(raw: string): OwnerEntry | null;
12
+ /** "@acme/design" for a team, "peterbono" for a person: the form Bridge writes back to bridge.config.json. */
13
+ export declare function formatOwnerEntry(e: OwnerEntry): string;
14
+ /** Whether a raw entry names a team. */
15
+ export declare const isTeamEntry: (raw: string) => boolean;
16
+ /** Case-insensitive key of a team: "@acme/design". */
17
+ export declare const teamKey: (org: string, slug: string) => string;
18
+ /** Key of a raw team entry, undefined for a person or a malformed entry. */
19
+ export declare function teamKeyOf(raw: string): string | undefined;
20
+ /**
21
+ * Problems in the raw owner lists, in the words loadConfig reports. Each malformed entry is named with the two
22
+ * accepted forms, so the fix is in the message.
23
+ */
24
+ export declare function ownerEntryProblems(raw: unknown): string[];
25
+ /** Why a team's members cannot be read, each with its own fix (teamUnreadableMessage). */
26
+ export type TeamUnreadableReason =
27
+ /** The repository owner (or the org named) is a personal account: GitHub teams only exist in organizations. */
28
+ 'personal-account'
29
+ /** The token works but may not read the organization's teams (classic: read:org; fine-grained: Members, read). */
30
+ | 'missing-permission'
31
+ /** No token, or GitHub refused it: GitHub never shows team members anonymously. */
32
+ | 'no-token'
33
+ /** GitHub says the team does not exist, or hides it from this token. */
34
+ | 'not-found'
35
+ /** GitHub did not answer (network, rate limit, server error). */
36
+ | 'unreachable';
37
+ export interface TeamRef {
38
+ /** "@acme/design", lowercase. */
39
+ key: string;
40
+ org: string;
41
+ slug: string;
42
+ }
43
+ export type TeamState = (TeamRef & {
44
+ status: 'ok';
45
+ members: string[];
46
+ }) | (TeamRef & {
47
+ status: 'unreadable';
48
+ reason: TeamUnreadableReason;
49
+ message: string;
50
+ });
51
+ /** Team states by key ("@acme/design"). */
52
+ export type TeamStates = Record<string, TeamState>;
53
+ /** The one sentence that says why a team cannot be read and how to fix it. */
54
+ export declare function teamUnreadableMessage(team: {
55
+ org: string;
56
+ slug: string;
57
+ }, reason: TeamUnreadableReason): string;
58
+ /** Distinct teams named in the owner lists, in order. */
59
+ export declare function teamsIn(owners: Partial<Owners> | readonly string[]): TeamRef[];
60
+ /**
61
+ * Logins of a list of entries: people as they are, a readable team as its members. An unreadable team, or one not
62
+ * resolved yet, stays as its "@org/slug" key: never empty, never matching a login.
63
+ */
64
+ export declare function expandEntries(entries: readonly string[], teams?: TeamStates | null): string[];
65
+ /** Both owner lists expanded (expandEntries). */
66
+ export declare function expandOwners(owners: Owners, teams?: TeamStates | null): Owners;
67
+ /** How an entry reads in a sentence: "@peterbono", "@acme/design (4 people)", "@acme/design" while unknown. */
68
+ export declare function entryLabel(raw: string, teams?: TeamStates | null): string;
69
+ /**
70
+ * The entries to name for a set of logins (someone who can still act): each person among them, each team with a
71
+ * member among them, and each team whose members are unknown (it may hold one). Labels as entryLabel.
72
+ */
73
+ export declare function entriesFor(entries: readonly string[], logins: readonly string[], teams?: TeamStates | null): string[];
74
+ /** Teams that could not be read, in order. */
75
+ export declare function unreadableTeams(teams: TeamStates | null | undefined): Extract<TeamState, {
76
+ status: 'unreadable';
77
+ }>[];
78
+ /** What TeamDirectory needs from a GitHub client. GitHubClient implements it. */
79
+ export interface TeamReader {
80
+ /** Whether a token is sent: GitHub never lists team members anonymously. */
81
+ readonly hasToken: boolean;
82
+ /** Every member login of a team (GET /orgs/{org}/teams/{slug}/members, all pages). */
83
+ listTeamMembers(org: string, slug: string): Promise<string[]>;
84
+ /** A user's membership of a team (GET /orgs/{org}/teams/{slug}/memberships/{user}): "active", "pending", or null when not a member. */
85
+ getTeamMembership(org: string, slug: string, login: string): Promise<'active' | 'pending' | null>;
86
+ /** "User" or "Organization" (GET /users/{login}), null when unknown. */
87
+ getAccountType(login: string): Promise<'User' | 'Organization' | null>;
88
+ /** The organization's teams the token can see (GET /orgs/{org}/teams, all pages). */
89
+ listOrgTeams(org: string): Promise<{
90
+ slug: string;
91
+ name: string;
92
+ }[]>;
93
+ }
94
+ export interface TeamDirectoryOptions {
95
+ /** How long a team read is kept, in ms. Default 5 minutes. */
96
+ ttlMs?: number;
97
+ now?: () => number;
98
+ }
99
+ /** Whether one person may act through a list of entries, and which teams could not be checked. */
100
+ export interface MembershipResult {
101
+ member: boolean;
102
+ /** Teams whose membership could not be read: when `member` is false, the answer may be wrong for them. */
103
+ unreadable: Extract<TeamState, {
104
+ status: 'unreadable';
105
+ }>[];
106
+ }
107
+ /**
108
+ * Expands teams to logins through a TeamReader, with a cache: each team is read once per `ttlMs`, concurrent reads
109
+ * of one team share the request. Every failure becomes an "unreadable" state with its reason.
110
+ */
111
+ export declare class TeamDirectory {
112
+ private readonly reader;
113
+ private readonly teams;
114
+ private readonly accounts;
115
+ private readonly memberships;
116
+ private readonly ttl;
117
+ private readonly now;
118
+ constructor(reader: TeamReader, opts?: TeamDirectoryOptions);
119
+ private accountType;
120
+ /** The reason a read of `org` failed, as a state. A 404 on a personal account says so; a 403 is a missing permission. */
121
+ private failure;
122
+ /** Members of one team, or why they cannot be read. Cached. */
123
+ team(org: string, slug: string): Promise<TeamState>;
124
+ /** Every team named in the owner lists, read in parallel. Empty object when no team is named (no request). */
125
+ resolve(owners: Partial<Owners> | readonly string[]): Promise<TeamStates>;
126
+ /**
127
+ * Whether `login` is an active member of one team: from the cached member list when there is one, else one
128
+ * membership read (GET /orgs/{org}/teams/{slug}/memberships/{user}). An unreadable team returns its state.
129
+ */
130
+ isMember(org: string, slug: string, login: string): Promise<boolean | TeamState>;
131
+ /**
132
+ * Whether `login` holds a right given to `entries` (people and teams): a person entry matches directly, a team
133
+ * through its membership. Teams are only read when no person entry matches.
134
+ */
135
+ memberOfAny(entries: readonly string[], login: string): Promise<MembershipResult>;
136
+ /** The organization's teams for a picker, or why they cannot be listed (a personal account has none). */
137
+ orgTeams(org: string): Promise<{
138
+ status: 'ok';
139
+ teams: {
140
+ slug: string;
141
+ name: string;
142
+ }[];
143
+ } | {
144
+ status: 'unreadable';
145
+ reason: TeamUnreadableReason;
146
+ }>;
147
+ /** Drops every cached read (after the token changes). */
148
+ clear(): void;
149
+ }
@@ -0,0 +1,78 @@
1
+ import { type FlatToken } from './types';
2
+ /** What one change brings: its token files where it started and now, and the token files it edits. */
3
+ export interface CombineSource {
4
+ number: number;
5
+ /** Token file texts where the change started (relative to the tokens root). Undefined: the file did not exist. */
6
+ base: Record<string, string | undefined>;
7
+ /** Token file texts in the change now. Undefined: the change removes the file. */
8
+ head: Record<string, string | undefined>;
9
+ /** Token files the change edits, relative to the tokens root. */
10
+ files: string[];
11
+ }
12
+ export interface CombineEdit {
13
+ /** `<file>#<token id>`: the same token in the same file across changes. */
14
+ key: string;
15
+ file: string;
16
+ /** The token's path in the change (its old path when the change removes it). */
17
+ path: string;
18
+ change: number;
19
+ /** The change's version of the token; undefined when the change removes it. */
20
+ token: FlatToken | undefined;
21
+ }
22
+ export interface ChangeEdits {
23
+ number: number;
24
+ edits: CombineEdit[];
25
+ /** Files that could not be read (not JSON). */
26
+ problems: string[];
27
+ }
28
+ /** The token edits of one change: every token whose version differs between where the change started and now. */
29
+ export declare function changeEdits(src: CombineSource): ChangeEdits;
30
+ /** One value a conflicting token can take, and the changes that set it. */
31
+ export interface CombineOption {
32
+ changes: number[];
33
+ token: FlatToken | undefined;
34
+ }
35
+ /** A token two or more changes set differently: the designer picks one change's value. */
36
+ export interface CombineConflict {
37
+ key: string;
38
+ file: string;
39
+ path: string;
40
+ /** Distinct values, in the order of the changes. */
41
+ options: CombineOption[];
42
+ }
43
+ /** The pick of each conflict: the change whose value the combination uses. */
44
+ export type CombinePicks = Record<string, number>;
45
+ export interface Combination {
46
+ /** Every token file of the latest library, with the combined edits applied (unchanged files as they are). */
47
+ files: Record<string, string>;
48
+ /** The changes combined, in order. */
49
+ included: number[];
50
+ /** Token values the combination edits (one per token and file: a token edited in light and dark counts twice). */
51
+ edits: number;
52
+ /** Tokens the combination edits, by name. */
53
+ tokens: number;
54
+ conflicts: CombineConflict[];
55
+ /** Conflicts without a pick that counts: left at the library's value ('skip') or given the first change's ('first'). */
56
+ missing: CombineConflict[];
57
+ /** Token path -> the changes whose value the combination uses for it. */
58
+ from: Record<string, number[]>;
59
+ problems: string[];
60
+ }
61
+ /** Tokens that two or more of these changes set differently. */
62
+ export declare function combineConflicts(changes: ChangeEdits[]): CombineConflict[];
63
+ /** The option a pick selects, or null when the picked change is not among them (not picked, or switched off). */
64
+ export declare function pickedOption(c: CombineConflict, picks: CombinePicks): CombineOption | null;
65
+ /** Conflicts that still need a pick. */
66
+ export declare const unpicked: (conflicts: CombineConflict[], picks: CombinePicks) => CombineConflict[];
67
+ /**
68
+ * The latest library plus the edits of the included changes (all of them by default, in their order). A conflict
69
+ * uses the picked change's value. Without a pick that counts, `unpicked: 'skip'` (Studio, before opening) leaves the
70
+ * token at the library's value and lists it in `missing`; `'first'` (the toolbar, when a picked change is switched
71
+ * off) uses the first included change's value and still lists it.
72
+ */
73
+ export declare function combineChanges(latest: Record<string, string>, changes: ChangeEdits[], picks?: CombinePicks, opts?: {
74
+ include?: number[];
75
+ unpicked?: 'skip' | 'first';
76
+ }): Combination;
77
+ export declare function encodePicks(picks: CombinePicks): string;
78
+ export declare function decodePicks(raw: string | null | undefined): CombinePicks;
@@ -0,0 +1,72 @@
1
+ import { type TokenDocument } from './dtcg-codec';
2
+ import { type FlatToken } from './types';
3
+ /** Which side a conflict keeps: this change ("Keep mine") or the latest library ("Use the library's"). */
4
+ export type MergeSide = 'mine' | 'theirs';
5
+ export interface TokenMergeConflict {
6
+ /** Stable key for the choice: `<file>#<token id>`. */
7
+ key: string;
8
+ /** Token file, relative to the tokens root. */
9
+ file: string;
10
+ /** Display path: this change's name, else the library's, else the shared one. */
11
+ path: string;
12
+ base?: FlatToken | undefined;
13
+ mine?: FlatToken | undefined;
14
+ theirs?: FlatToken | undefined;
15
+ /** The side that removed the token while the other changed it. Null when both sides kept it. */
16
+ removedOn: MergeSide | null;
17
+ /** "Keep mine" by default; null when the token was removed on one side and changed on the other. */
18
+ defaultChoice: MergeSide | null;
19
+ }
20
+ export interface TokenMergePlan {
21
+ /** Token files both sides changed, relative to the tokens root. */
22
+ files: string[];
23
+ conflicts: TokenMergeConflict[];
24
+ /** Tokens only this change touched: applied on top of the library as they are. */
25
+ mineOnly: number;
26
+ /** Tokens only the library touched: kept as the library has them. */
27
+ theirsOnly: number;
28
+ /** Files that could not be read (not JSON): nothing can be merged while there is one. */
29
+ problems: string[];
30
+ }
31
+ export interface TokenMergeInput {
32
+ /** File texts at the commit both sides share. Undefined: the file did not exist there. */
33
+ base: Record<string, string | undefined>;
34
+ /** File texts in the change. */
35
+ mine: Record<string, string | undefined>;
36
+ /** File texts in the latest library. */
37
+ theirs: Record<string, string | undefined>;
38
+ /** Token files both sides changed (the others need no merge). */
39
+ files: string[];
40
+ }
41
+ export type MergeChoices = Record<string, MergeSide | undefined>;
42
+ /** The side that removed a token the other side changed, or null. */
43
+ export declare function removedSide(c: {
44
+ base?: unknown;
45
+ mine?: unknown;
46
+ theirs?: unknown;
47
+ }): MergeSide | null;
48
+ /** "Keep mine" by default, except for a token removed on one side and changed on the other: no default there. */
49
+ export declare const defaultChoiceFor: (c: {
50
+ base?: unknown;
51
+ mine?: unknown;
52
+ theirs?: unknown;
53
+ }) => MergeSide | null;
54
+ /** What both sides did to the token files they both changed, and which tokens need a decision. */
55
+ export declare function planTokenMerge(input: TokenMergeInput): TokenMergePlan;
56
+ /** The side a conflict keeps: the designer's pick, else its default (null: still to pick). */
57
+ export declare const choiceFor: (c: TokenMergeConflict, choices: MergeChoices) => MergeSide | null;
58
+ /** Conflicts that still need a pick: removed on one side, changed on the other, and not decided yet. */
59
+ export declare function missingChoices(plan: Pick<TokenMergePlan, 'conflicts'>, choices: MergeChoices): TokenMergeConflict[];
60
+ /** Puts this change's version of a token into the library's file: its value, type, description and extensions. Also used by token-combine. */
61
+ export declare function takeMine(doc: TokenDocument, mine: FlatToken | undefined, existing: FlatToken | undefined): boolean;
62
+ export interface TokenMergeResult {
63
+ /** Merged text per file (relative to the tokens root); null when the merged file holds no token and is removed. */
64
+ files: Record<string, string | null>;
65
+ /** How many conflicts kept each side. */
66
+ kept: Record<MergeSide, number>;
67
+ }
68
+ /**
69
+ * The merged token files: the latest library's text, plus every token only this change touched, plus each conflict
70
+ * as chosen. Throws when a file could not be read or a conflict still needs a pick.
71
+ */
72
+ export declare function applyTokenMerge(input: TokenMergeInput, choices: MergeChoices): TokenMergeResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge-ds/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Bridge core: bridge.config.json, DTCG token loading (tiers and layered modes), alias resolution, WCAG contrast, diffs, minimal-diff edits, PR body, the shared risk tier classifier, changesets and a fetch-based GitHub client. No Figma API, no DOM; Node-only helpers (git, reading a product folder) under @bridge-ds/core/node; the adoption audit under @bridge-ds/core/audit.",
6
6
  "license": "MIT",