@shortlink-org/portolan 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/portolan-go.wasm +0 -0
  5. package/scripts/builtin-plugins.mjs +3 -2
  6. package/scripts/catalog-sources.mjs +2 -1
  7. package/scripts/catalog-sources.test.mjs +11 -1
  8. package/scripts/delivery-presets.mjs +207 -24
  9. package/scripts/diff.mjs +5 -1
  10. package/scripts/local-api.mjs +26 -377
  11. package/scripts/local-api.test.mjs +50 -0
  12. package/scripts/local-discovery.mjs +378 -0
  13. package/scripts/manifest.mjs +42 -1
  14. package/scripts/manifest.test.mjs +24 -1
  15. package/scripts/run-builtin.mjs +4 -2
  16. package/scripts/schema.mjs +4 -0
  17. package/scripts/site-docs.mjs +2 -2
  18. package/src/app/CatalogApp.tsx +4 -4
  19. package/src/app/Sidebar.tsx +13 -730
  20. package/src/app/SidebarFlowSections.tsx +251 -0
  21. package/src/app/SidebarFooter.tsx +160 -0
  22. package/src/app/SidebarTree.tsx +322 -0
  23. package/src/catalog-index.ts +485 -0
  24. package/src/catalog-model.ts +1339 -0
  25. package/src/catalog-validation.ts +1570 -0
  26. package/src/catalog.test.ts +16 -0
  27. package/src/catalog.ts +6 -3306
  28. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  29. package/src/components/ProblemRow.tsx +3 -0
  30. package/src/components/SourcePreview.tsx +1 -1
  31. package/src/index.css +0 -17
  32. package/src/landing/LandingPage.tsx +4 -4
  33. package/src/lib/all-problems.ts +1 -1
  34. package/src/lib/derive.ts +1 -0
  35. package/src/lib/local-api.ts +19 -7
  36. package/src/lib/motion.test.ts +4 -2
  37. package/src/lib/motion.tsx +5 -4
  38. package/src/lib/proto-problems.test.ts +170 -3
  39. package/src/lib/proto-problems.ts +176 -4
  40. package/src/merge.test.ts +46 -0
  41. package/src/merge.ts +35 -1
  42. package/src/pages/Settings.tsx +1 -126
  43. package/src/pages/settings/AboutSettings.tsx +129 -0
  44. package/src/pages/settings/DeliverySettings.tsx +71 -15
  45. package/src/selection/pages.test.ts +14 -1
  46. package/src/selection/pages.ts +9 -3
  47. package/vite.config.ts +3 -1
@@ -1,4 +1,3 @@
1
- import { createElement } from "react";
2
1
  import { renderToStaticMarkup } from "react-dom/server";
3
2
  import { describe, expect, it } from "vitest";
4
3
 
@@ -6,15 +5,15 @@ import { CapabilityEmpty } from "./PageHeader";
6
5
 
7
6
  describe("CapabilityEmpty", () => {
8
7
  it("keeps the explanation, action and evidence signal together", () => {
9
- const markup = renderToStaticMarkup(createElement(
10
- CapabilityEmpty,
11
- {
12
- title: "No dependency graph yet",
13
- actions: createElement("button", { type: "button" }, "Review extraction"),
14
- signal: "Signals: domain events and consumers.",
15
- },
16
- "The catalog has components, but no events to connect.",
17
- ));
8
+ const markup = renderToStaticMarkup(
9
+ <CapabilityEmpty
10
+ title="No dependency graph yet"
11
+ actions={<button type="button">Review extraction</button>}
12
+ signal="Signals: domain events and consumers."
13
+ >
14
+ The catalog has components, but no events to connect.
15
+ </CapabilityEmpty>,
16
+ );
18
17
 
19
18
  expect(markup).toContain("next useful step");
20
19
  expect(markup).toContain("No dependency graph yet");
@@ -27,6 +27,7 @@ const KIND_OF: Record<Problem["kind"], "service" | "event" | "table"> = {
27
27
  "column-type": "table",
28
28
  "outbox-payload": "table",
29
29
  "proto-missing": "service",
30
+ "proto-drift": "service",
30
31
  "shared-channel": "event",
31
32
  "channel-undeclared": "event",
32
33
  "channel-unpublished": "service",
@@ -44,6 +45,7 @@ const KIND_NOTE: Record<Problem["kind"], string> = {
44
45
  "outbox-payload": "an outbox with no payload column",
45
46
  "proto-missing":
46
47
  "the provider is in the catalog but answers on no such method",
48
+ "proto-drift": "the vendored proto and provider schema disagree",
47
49
  "shared-channel": "a second service publishes on this channel",
48
50
  "channel-undeclared":
49
51
  "this event goes out on a channel the service does not declare",
@@ -60,6 +62,7 @@ function nearPath(problem: Problem): string | null {
60
62
  // peer is nobody; this is a call whose peer is known and whose method is
61
63
  // not - and in both the thing to go and look at is the caller.
62
64
  case "proto-missing":
65
+ case "proto-drift":
63
66
  return servicePath(problem.service);
64
67
  case "consumer":
65
68
  case "channel-undeclared":
@@ -106,7 +106,7 @@ function AccessForm({
106
106
  <button
107
107
  type="submit"
108
108
  disabled={!draft.trim()}
109
- className="btn-accent shrink-0 disabled:opacity-40"
109
+ className="product-primary shrink-0 disabled:opacity-40"
110
110
  >
111
111
  {connected ? "Replace token" : "Connect"}
112
112
  </button>
package/src/index.css CHANGED
@@ -573,22 +573,6 @@
573
573
  background: color-mix(in srgb, var(--accent) 10%, transparent);
574
574
  }
575
575
 
576
- /* Sanctioned gradient 2 - the accent surface. Reserved for a primary button;
577
- white on --accent-lo clears 4.5:1 in both themes. */
578
- .btn-accent {
579
- @apply mono flex items-center gap-1.5 rounded-control px-3 py-1.5;
580
- color: #fff;
581
- background-image: linear-gradient(
582
- to bottom,
583
- var(--accent-hi),
584
- var(--accent-lo)
585
- );
586
- transition: filter var(--dur-micro) var(--ease-out);
587
- }
588
- .btn-accent:hover {
589
- filter: brightness(1.06);
590
- }
591
-
592
576
  /* The landing's primary action should feel like a control from the map,
593
577
  not a bright rectangle pasted over it. The depth is always present; the
594
578
  brief chart-line sweep only appears when a reader points at the action. */
@@ -697,7 +681,6 @@
697
681
  outline: 2px solid color-mix(in srgb, var(--accent) 55%, white);
698
682
  outline-offset: 3px;
699
683
  }
700
- .btn-accent:disabled,
701
684
  .product-primary:disabled,
702
685
  .tbtn:disabled {
703
686
  cursor: not-allowed;
@@ -97,7 +97,7 @@ function Header() {
97
97
  >
98
98
  <GitBranch size={15} /> GitHub
99
99
  </a>
100
- <Link to={exampleTo} className="btn-accent landing-primary">
100
+ <Link to={exampleTo} className="landing-primary">
101
101
  Explore example <ArrowRight size={14} />
102
102
  </Link>
103
103
  </div>
@@ -319,7 +319,7 @@ export function LandingPage() {
319
319
  >
320
320
  <Link
321
321
  to={exampleTo}
322
- className="btn-accent landing-primary px-4 py-2.5"
322
+ className="landing-primary px-4 py-2.5"
323
323
  >
324
324
  Explore example catalog <ArrowRight size={15} />
325
325
  </Link>
@@ -647,7 +647,7 @@ export function LandingPage() {
647
647
  <div className="mt-4 flex flex-wrap gap-3">
648
648
  <Link
649
649
  to={exampleTo}
650
- className="btn-accent landing-primary px-4 py-2.5"
650
+ className="landing-primary px-4 py-2.5"
651
651
  >
652
652
  Open the example <ArrowRight size={15} />
653
653
  </Link>
@@ -719,7 +719,7 @@ export function LandingPage() {
719
719
  <div className="mt-8 flex flex-wrap justify-center gap-3">
720
720
  <Link
721
721
  to={exampleTo}
722
- className="btn-accent landing-primary px-4 py-2.5"
722
+ className="landing-primary px-4 py-2.5"
723
723
  >
724
724
  Explore example catalog <ArrowRight size={15} />
725
725
  </Link>
@@ -1,7 +1,7 @@
1
1
  // Every reading the catalog makes of itself, in one list.
2
2
  //
3
3
  // Four readers, each answering a different question: edges that resolve to
4
- // nothing, schemas that name a method the module lacks, tables and columns
4
+ // nothing, producer and consumer schemas that disagree, tables and columns
5
5
  // that disagree with the model, and channels that documents and events do not
6
6
  // agree on. The problems page, the overview and the landing all want the
7
7
  // union, errors first - a boundary leak is not the same kind of news as a
package/src/lib/derive.ts CHANGED
@@ -420,6 +420,7 @@ export type ProblemKind =
420
420
  | "column-type"
421
421
  | "outbox-payload"
422
422
  | "proto-missing"
423
+ | "proto-drift"
423
424
  | "shared-channel"
424
425
  | "channel-undeclared"
425
426
  | "channel-unpublished"
@@ -70,10 +70,20 @@ export interface GeneratedFileDiff {
70
70
  }
71
71
 
72
72
  export type DeliveryProvider = "github" | "gitlab";
73
+ export type DeliveryFeatureId = "check" | "diff" | "sarif" | "pages";
74
+
75
+ export interface DeliveryFeature {
76
+ id: DeliveryFeatureId;
77
+ label: string;
78
+ description: string;
79
+ selected: boolean;
80
+ available: boolean;
81
+ requires: DeliveryFeatureId[];
82
+ }
73
83
 
74
84
  export interface DeliveryPresetFile {
75
85
  path: string;
76
- status: "added" | "changed" | "unchanged" | "conflict";
86
+ status: "added" | "changed" | "removed" | "unchanged" | "conflict";
77
87
  diff: string;
78
88
  message?: string;
79
89
  }
@@ -86,7 +96,7 @@ export interface DeliveryPreset {
86
96
  defaultBranch: string;
87
97
  status: "available" | "installed" | "update" | "conflict";
88
98
  revision: string;
89
- features: string[];
99
+ features: DeliveryFeature[];
90
100
  files: DeliveryPresetFile[];
91
101
  }
92
102
 
@@ -168,16 +178,18 @@ export async function localStatus(): Promise<{ local: true; workspace: string; s
168
178
  return json("/status");
169
179
  }
170
180
 
171
- export async function previewDeliveryPreset(provider?: DeliveryProvider): Promise<DeliveryPreset> {
172
- const query = provider ? `?provider=${encodeURIComponent(provider)}` : "";
173
- return json(`/delivery-presets${query}`);
181
+ export async function previewDeliveryPreset(provider?: DeliveryProvider, features?: DeliveryFeatureId[]): Promise<DeliveryPreset> {
182
+ const query = new URLSearchParams();
183
+ if (provider) query.set("provider", provider);
184
+ if (features) query.set("features", features.join(","));
185
+ return json(`/delivery-presets${query.size ? `?${query}` : ""}`);
174
186
  }
175
187
 
176
- export async function installDeliveryPreset(provider: DeliveryProvider, revision: string): Promise<DeliveryPreset & { written: string[] }> {
188
+ export async function installDeliveryPreset(provider: DeliveryProvider, revision: string, features: DeliveryFeatureId[]): Promise<DeliveryPreset & { written: string[] }> {
177
189
  return json("/delivery-presets/install", {
178
190
  method: "POST",
179
191
  headers: LOCAL_HEADER,
180
- body: JSON.stringify({ provider, revision }),
192
+ body: JSON.stringify({ provider, revision, features }),
181
193
  });
182
194
  }
183
195
 
@@ -102,10 +102,12 @@ describe("presence", () => {
102
102
  expect(rise.initial.y).toBe(8);
103
103
  });
104
104
 
105
- it("a page arrives on the page duration and leaves faster, opacity only", () => {
105
+ it("a page rises in and lifts out faster", () => {
106
106
  expect(page.animate.transition).toBe(transitions.page);
107
107
  expect(page.exit.transition).toBe(transitions.micro);
108
- expect(Object.keys(page.exit)).toEqual(["opacity", "transition"]);
108
+ expect(page.initial.y).toBe(8);
109
+ expect(page.animate.y).toBe(0);
110
+ expect(page.exit.y).toBe(-4);
109
111
  });
110
112
 
111
113
  it("scaleIn grows the two percent palette-in grows", () => {
@@ -91,14 +91,15 @@ export const scaleIn: Presence = {
91
91
  };
92
92
 
93
93
  /**
94
- * A route: rises the eight pixels on the page duration, and leaves on the
95
- * micro duration, opacity only. The reader has already asked for the next
96
- * page; the old one should be out of the way, not making an exit.
94
+ * A route: rises the eight pixels on the page duration while the old page
95
+ * lifts four pixels and fades on the micro duration. Route presence uses
96
+ * `popLayout`, so those two short movements overlap instead of inserting a
97
+ * blank beat between pages.
97
98
  */
98
99
  export const page: Presence = {
99
100
  initial: { opacity: 0, y: 8 },
100
101
  animate: { opacity: 1, y: 0, transition: transitions.page },
101
- exit: { opacity: 0, transition: transitions.micro },
102
+ exit: { opacity: 0, y: -4, transition: transitions.micro },
102
103
  };
103
104
 
104
105
  /** A row, a toast: rises the same eight pixels a page rises. */
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { buildIndex, type Catalog, type Service } from "../catalog";
2
+ import type { Catalog, CatalogIndex, Service } from "../catalog";
3
3
  import { protoProblems } from "./proto-problems";
4
4
 
5
5
  function service(id: string, overrides: Partial<Service> = {}): Service {
@@ -43,8 +43,39 @@ const pricing = () =>
43
43
  provides: [
44
44
  {
45
45
  id: "pricing.v1.Pricing",
46
- methods: [{ name: "GetQuote" }],
46
+ methods: [
47
+ {
48
+ name: "GetQuote",
49
+ request: "GetQuoteRequest",
50
+ response: "Quote",
51
+ },
52
+ ],
47
53
  source: "proto/pricing/v1/pricing.proto:7",
54
+ messages: [
55
+ {
56
+ name: "GetQuoteRequest",
57
+ fields: [
58
+ { name: "sku", type: "string", number: 1, doc: "" },
59
+ { name: "locale", type: "string", number: 2, doc: "" },
60
+ ],
61
+ },
62
+ {
63
+ name: "Quote",
64
+ fields: [
65
+ { name: "amount", type: "int64", number: 1, doc: "" },
66
+ { name: "status", type: "QuoteStatus", number: 2, doc: "" },
67
+ ],
68
+ },
69
+ ],
70
+ enums: [
71
+ {
72
+ name: "QuoteStatus",
73
+ values: [
74
+ { name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
75
+ { name: "QUOTE_STATUS_READY", number: 1 },
76
+ ],
77
+ },
78
+ ],
48
79
  },
49
80
  ],
50
81
  });
@@ -61,8 +92,75 @@ const calling = (id: string, status: "declared" | "unresolved" = "declared") =>
61
92
  ],
62
93
  });
63
94
 
95
+ const copiedPricing = (overrides = {}) =>
96
+ service("shop.oms", {
97
+ consumes: [
98
+ {
99
+ id: "pricing.v1.Pricing/GetQuote",
100
+ peer: "shop.pricing",
101
+ status: "declared",
102
+ source: "internal/infrastructure/pricing/pricing.proto:8",
103
+ },
104
+ ],
105
+ copies: [
106
+ {
107
+ id: "pricing.v1.Pricing",
108
+ methods: [
109
+ {
110
+ name: "GetQuote",
111
+ request: "GetQuoteRequest",
112
+ response: "Quote",
113
+ },
114
+ ],
115
+ source: "internal/infrastructure/pricing/pricing.proto:7",
116
+ messages: [
117
+ {
118
+ name: "GetQuoteRequest",
119
+ fields: [{ name: "sku", type: "string", number: 1, doc: "" }],
120
+ },
121
+ {
122
+ name: "Quote",
123
+ fields: [
124
+ { name: "amount", type: "int64", number: 1, doc: "" },
125
+ { name: "status", type: "QuoteStatus", number: 2, doc: "" },
126
+ ],
127
+ },
128
+ ],
129
+ enums: [
130
+ {
131
+ name: "QuoteStatus",
132
+ values: [
133
+ { name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
134
+ { name: "QUOTE_STATUS_READY", number: 1 },
135
+ ],
136
+ },
137
+ ],
138
+ ...overrides,
139
+ },
140
+ ],
141
+ });
142
+
64
143
  function found(catalog: Catalog) {
65
- return protoProblems(catalog, buildIndex(catalog));
144
+ const serviceById = new Map<string, Service>();
145
+ const serviceContext = new Map<string, Catalog["contexts"][number]>();
146
+ const rpcProviderByMethod = new Map<string, Service>();
147
+ for (const context of catalog.contexts) {
148
+ for (const item of context.services) {
149
+ serviceById.set(item.id, item);
150
+ serviceContext.set(item.id, context);
151
+ for (const provided of item.provides) {
152
+ for (const method of provided.methods) {
153
+ rpcProviderByMethod.set(`${provided.id}/${method.name}`, item);
154
+ }
155
+ }
156
+ }
157
+ }
158
+ const index = {
159
+ serviceById,
160
+ serviceContext,
161
+ rpcProviderByMethod,
162
+ } as CatalogIndex;
163
+ return protoProblems(catalog, index);
66
164
  }
67
165
 
68
166
  describe("protoProblems", () => {
@@ -118,4 +216,73 @@ describe("protoProblems", () => {
118
216
  it("finds nothing in a catalog where nobody calls anybody", () => {
119
217
  expect(found(catalogWith([pricing()]))).toEqual([]);
120
218
  });
219
+
220
+ it("accepts a narrowed copy whose retained fields and enum agree", () => {
221
+ expect(found(catalogWith([pricing(), copiedPricing()]))).toEqual([]);
222
+ });
223
+
224
+ it("reports copied fields whose type, number, or existence drifted", () => {
225
+ const caller = copiedPricing({
226
+ messages: [
227
+ {
228
+ name: "GetQuoteRequest",
229
+ fields: [
230
+ { name: "sku", type: "bytes", number: 3, doc: "" },
231
+ { name: "currency", type: "string", number: 4, doc: "" },
232
+ ],
233
+ },
234
+ ],
235
+ });
236
+
237
+ const problems = found(catalogWith([pricing(), caller]));
238
+
239
+ expect(problems).toHaveLength(1);
240
+ expect(problems[0]).toMatchObject({
241
+ kind: "proto-drift",
242
+ severity: "warning",
243
+ service: "shop.oms",
244
+ id: "pricing.v1.Pricing",
245
+ peer: "shop.pricing",
246
+ });
247
+ expect(problems[0]?.note).toContain("GetQuoteRequest.sku is bytes");
248
+ expect(problems[0]?.note).toContain("GetQuoteRequest.sku is field 3");
249
+ expect(problems[0]?.note).toContain(
250
+ "GetQuoteRequest.currency is absent from the provider",
251
+ );
252
+ });
253
+
254
+ it("reports enum values added, removed, or renumbered", () => {
255
+ const caller = copiedPricing({
256
+ enums: [
257
+ {
258
+ name: "QuoteStatus",
259
+ values: [
260
+ { name: "QUOTE_STATUS_UNSPECIFIED", number: 0 },
261
+ { name: "QUOTE_STATUS_READY", number: 7 },
262
+ { name: "QUOTE_STATUS_OLD", number: 1 },
263
+ ],
264
+ },
265
+ ],
266
+ });
267
+
268
+ const [problem] = found(catalogWith([pricing(), caller]));
269
+
270
+ expect(problem?.note).toContain("QUOTE_STATUS_READY is 7, provider has 1");
271
+ expect(problem?.note).toContain("QUOTE_STATUS_OLD is absent from the provider");
272
+ });
273
+
274
+ it("reports an enum value present only in the provider", () => {
275
+ const caller = copiedPricing({
276
+ enums: [
277
+ {
278
+ name: "QuoteStatus",
279
+ values: [{ name: "QUOTE_STATUS_UNSPECIFIED", number: 0 }],
280
+ },
281
+ ],
282
+ });
283
+
284
+ const [problem] = found(catalogWith([pricing(), caller]));
285
+
286
+ expect(problem?.note).toContain("QUOTE_STATUS_READY is missing from the copy");
287
+ });
121
288
  });
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // docs/adr/org.0001.md promises this: producers publish their schema, consumers
4
4
  // keep a narrowed copy, and "a field, method or enum value that differs between
5
- // the two is reported against the consuming service". This is the first half of
6
- // that promise - a call naming a method no provider declares.
5
+ // the two is reported against the consuming service". The extractor retains
6
+ // those copies on the consumer; this reader compares them after every source
7
+ // has met in the merged catalog.
7
8
  //
8
9
  // It is deliberately NOT done in the merge. src/merge.ts is documented as
9
10
  // union-by-id plus first-non-empty and nothing else, and teaching it to compare
@@ -16,11 +17,20 @@
16
17
  // is about a call whose peer is known and whose METHOD is not - the copy is
17
18
  // stale, or the producer removed something, and the two are different repairs.
18
19
 
19
- import type { Catalog, CatalogIndex } from "../catalog";
20
+ import type {
21
+ Catalog,
22
+ CatalogIndex,
23
+ RpcEnum,
24
+ RpcMessage,
25
+ RpcMethod,
26
+ RpcService,
27
+ Service,
28
+ } from "../catalog";
20
29
  import type { Problem } from "./derive";
21
30
 
22
31
  /**
23
- * Every call that names a method no interface in the catalog declares.
32
+ * Every missing method and every retained consumer descriptor that disagrees
33
+ * with the provider's method, field, or enum value.
24
34
  *
25
35
  * An unresolved call is skipped: it already has a problem of its own from
26
36
  * `problems()`, and reporting the same edge twice under two headings would make
@@ -58,8 +68,170 @@ export function protoProblems(
58
68
  source: call.source,
59
69
  });
60
70
  }
71
+
72
+ for (const copy of service.copies ?? []) {
73
+ const drift = driftAgainstProvider(service, copy, index);
74
+ if (drift) out.push(drift);
75
+ }
61
76
  }
62
77
  }
63
78
 
64
79
  return out;
65
80
  }
81
+
82
+ /** Compare one consumer copy only when one of its calls resolves to a provider. */
83
+ function driftAgainstProvider(
84
+ consumer: Service,
85
+ copy: RpcService,
86
+ index: CatalogIndex,
87
+ ): Problem | undefined {
88
+ const calls = new Map(
89
+ consumer.consumes
90
+ .filter((call) => call.id.startsWith(`${copy.id}/`))
91
+ .map((call) => [call.id, call]),
92
+ );
93
+ const resolved = copy.methods
94
+ .map((method) => calls.get(`${copy.id}/${method.name}`))
95
+ .find(
96
+ (call) =>
97
+ call?.status !== "unresolved" && index.serviceById.has(call?.peer ?? ""),
98
+ );
99
+ if (!resolved) return undefined;
100
+
101
+ const providerService = index.serviceById.get(resolved.peer);
102
+ const provider = providerService?.provides.find(
103
+ (candidate) => candidate.id === copy.id,
104
+ );
105
+ if (!providerService || !provider) return undefined;
106
+
107
+ const differences = compareInterfaces(copy, provider);
108
+ if (differences.length === 0) return undefined;
109
+
110
+ return {
111
+ kind: "proto-drift",
112
+ severity: "warning",
113
+ context: index.serviceContext.get(consumer.id)?.id ?? "",
114
+ service: consumer.id,
115
+ id: copy.id,
116
+ peer: providerService.id,
117
+ note: `Vendored copy differs from ${providerService.id}: ${differences.join("; ")}.`,
118
+ source: copy.source,
119
+ };
120
+ }
121
+
122
+ function compareInterfaces(copy: RpcService, provider: RpcService): string[] {
123
+ const out: string[] = [];
124
+ const methods = new Map(provider.methods.map((method) => [method.name, method]));
125
+
126
+ for (const method of copy.methods) {
127
+ const actual = methods.get(method.name);
128
+ // A missing method already has the stronger proto-missing error.
129
+ if (!actual) continue;
130
+ compareMethod(method, actual, out);
131
+ }
132
+
133
+ const messages = new Map(
134
+ (provider.messages ?? []).map((message) => [message.name, message]),
135
+ );
136
+ for (const message of copy.messages ?? []) {
137
+ compareMessage(message, messages.get(message.name), out);
138
+ }
139
+
140
+ const enums = new Map(
141
+ (provider.enums ?? []).map((item) => [item.name, item]),
142
+ );
143
+ for (const item of copy.enums ?? []) {
144
+ compareEnum(item, enums.get(item.name), out);
145
+ }
146
+
147
+ return out;
148
+ }
149
+
150
+ function compareMethod(
151
+ copy: RpcMethod,
152
+ provider: RpcMethod,
153
+ out: string[],
154
+ ): void {
155
+ for (const side of ["request", "response"] as const) {
156
+ if (copy[side] !== provider[side]) {
157
+ out.push(
158
+ `${copy.name} ${side} is ${copy[side] || "unnamed"}, provider has ${provider[side] || "unnamed"}`,
159
+ );
160
+ }
161
+ }
162
+ if (copy.streaming !== provider.streaming) {
163
+ out.push(
164
+ `${copy.name} streaming is ${copy.streaming ?? "unary"}, provider has ${provider.streaming ?? "unary"}`,
165
+ );
166
+ }
167
+ }
168
+
169
+ /**
170
+ * A narrowed copy may omit provider fields it does not read. Every field it
171
+ * does carry must still have the provider's type and protobuf number.
172
+ */
173
+ function compareMessage(
174
+ copy: RpcMessage,
175
+ provider: RpcMessage | undefined,
176
+ out: string[],
177
+ ): void {
178
+ if (!provider) {
179
+ out.push(`message ${copy.name} is absent from the provider`);
180
+ return;
181
+ }
182
+
183
+ const fields = new Map(provider.fields.map((field) => [field.name, field]));
184
+ for (const field of copy.fields) {
185
+ const actual = fields.get(field.name);
186
+ if (!actual) {
187
+ out.push(`${copy.name}.${field.name} is absent from the provider`);
188
+ continue;
189
+ }
190
+ if (field.type !== actual.type) {
191
+ out.push(
192
+ `${copy.name}.${field.name} is ${field.type}, provider has ${actual.type}`,
193
+ );
194
+ }
195
+ if (
196
+ field.number !== undefined &&
197
+ actual.number !== undefined &&
198
+ field.number !== actual.number
199
+ ) {
200
+ out.push(
201
+ `${copy.name}.${field.name} is field ${field.number}, provider has ${actual.number}`,
202
+ );
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Enum names and numbers are both wire claims, so the sets must match whole. */
208
+ function compareEnum(
209
+ copy: RpcEnum,
210
+ provider: RpcEnum | undefined,
211
+ out: string[],
212
+ ): void {
213
+ if (!provider) {
214
+ out.push(`enum ${copy.name} is absent from the provider`);
215
+ return;
216
+ }
217
+
218
+ const copyByName = new Map(copy.values.map((value) => [value.name, value]));
219
+ const providerByName = new Map(
220
+ provider.values.map((value) => [value.name, value]),
221
+ );
222
+ for (const value of copy.values) {
223
+ const actual = providerByName.get(value.name);
224
+ if (!actual) {
225
+ out.push(`${copy.name}.${value.name} is absent from the provider`);
226
+ } else if (value.number !== actual.number) {
227
+ out.push(
228
+ `${copy.name}.${value.name} is ${value.number}, provider has ${actual.number}`,
229
+ );
230
+ }
231
+ }
232
+ for (const value of provider.values) {
233
+ if (!copyByName.has(value.name)) {
234
+ out.push(`${copy.name}.${value.name} is missing from the copy`);
235
+ }
236
+ }
237
+ }