@shortlink-org/portolan 0.2.1 → 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.
- package/README.md +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
package/src/lib/local-api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SetupInfo, SetupPhase, SetupRunStepStatus } from "./setup-info";
|
|
1
|
+
import type { SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
|
|
2
2
|
|
|
3
3
|
const ROOT = `${import.meta.env.BASE_URL}__portolan`;
|
|
4
4
|
const LOCAL_HEADER = { "Content-Type": "application/json", "X-Portolan-Local": "1" };
|
|
@@ -18,6 +18,7 @@ export interface Discovery {
|
|
|
18
18
|
truncated: boolean;
|
|
19
19
|
components: Array<{ path: string; name: string; markers: string[]; technologies: string[] }>;
|
|
20
20
|
componentsTruncated: boolean;
|
|
21
|
+
deployables: Array<{ slug: string; name: string; path: string; kind: "service"; confidence: "high" | "medium"; evidence: string[] }>;
|
|
21
22
|
defaults: { id: string; name: string; group: string; component: string; context: string; service: string };
|
|
22
23
|
detections: Detection[];
|
|
23
24
|
}
|
|
@@ -35,13 +36,17 @@ export interface ProjectDraft {
|
|
|
35
36
|
component: string;
|
|
36
37
|
context?: string;
|
|
37
38
|
service?: string;
|
|
39
|
+
contextName?: string;
|
|
40
|
+
contextSummary?: string;
|
|
41
|
+
classification?: "core" | "supporting" | "generic";
|
|
42
|
+
replaceStarter?: boolean;
|
|
38
43
|
groupKind?: "bounded-context" | "system" | "product" | "team" | "namespace";
|
|
39
44
|
componentKind?: "service" | "application" | "webapp" | "worker" | "job" | "function" | "cli" | "library" | "data-pipeline";
|
|
40
45
|
plugins: string[];
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface ProjectPlan {
|
|
44
|
-
project: { id: string; name: string; root: string; group?: string; component?: string; groupKind?: string; componentKind?: string; context?: string; service?: string; repository?: string };
|
|
49
|
+
project: { id: string; name: string; root: string; group?: string; component?: string; components?: string[]; groupKind?: string; componentKind?: string; context?: string; service?: string; repository?: string };
|
|
45
50
|
plugins: string[];
|
|
46
51
|
steps: Array<{ plugin: string; in: string; out: string; options: Record<string, unknown> }>;
|
|
47
52
|
source: string;
|
|
@@ -65,10 +70,20 @@ export interface GeneratedFileDiff {
|
|
|
65
70
|
}
|
|
66
71
|
|
|
67
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
|
+
}
|
|
68
83
|
|
|
69
84
|
export interface DeliveryPresetFile {
|
|
70
85
|
path: string;
|
|
71
|
-
status: "added" | "changed" | "unchanged" | "conflict";
|
|
86
|
+
status: "added" | "changed" | "removed" | "unchanged" | "conflict";
|
|
72
87
|
diff: string;
|
|
73
88
|
message?: string;
|
|
74
89
|
}
|
|
@@ -81,7 +96,7 @@ export interface DeliveryPreset {
|
|
|
81
96
|
defaultBranch: string;
|
|
82
97
|
status: "available" | "installed" | "update" | "conflict";
|
|
83
98
|
revision: string;
|
|
84
|
-
features:
|
|
99
|
+
features: DeliveryFeature[];
|
|
85
100
|
files: DeliveryPresetFile[];
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -163,16 +178,18 @@ export async function localStatus(): Promise<{ local: true; workspace: string; s
|
|
|
163
178
|
return json("/status");
|
|
164
179
|
}
|
|
165
180
|
|
|
166
|
-
export async function previewDeliveryPreset(provider?: DeliveryProvider): Promise<DeliveryPreset> {
|
|
167
|
-
const query =
|
|
168
|
-
|
|
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}` : ""}`);
|
|
169
186
|
}
|
|
170
187
|
|
|
171
|
-
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[] }> {
|
|
172
189
|
return json("/delivery-presets/install", {
|
|
173
190
|
method: "POST",
|
|
174
191
|
headers: LOCAL_HEADER,
|
|
175
|
-
body: JSON.stringify({ provider, revision }),
|
|
192
|
+
body: JSON.stringify({ provider, revision, features }),
|
|
176
193
|
});
|
|
177
194
|
}
|
|
178
195
|
|
|
@@ -196,15 +213,23 @@ export async function previewProject(draft: ProjectDraft): Promise<ProjectPlan>
|
|
|
196
213
|
return json("/projects/preview", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
export async function addProject(draft: ProjectDraft): Promise<ProjectPlan & { setup: SetupInfo }> {
|
|
216
|
+
export async function addProject(draft: ProjectDraft): Promise<ProjectPlan & { undoToken: string; setup: SetupInfo }> {
|
|
200
217
|
return json("/projects", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
|
|
201
218
|
}
|
|
202
219
|
|
|
220
|
+
export async function removeProject(id: string): Promise<{ project: SetupProject; removedOutputs: string[]; undoToken: string; setup: SetupInfo }> {
|
|
221
|
+
return json(`/projects/${encodeURIComponent(id)}/remove`, { method: "POST", headers: LOCAL_HEADER, body: "{}" });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function undoProjectRemoval(undoToken: string): Promise<{ restored: true; setup: SetupInfo }> {
|
|
225
|
+
return json("/projects/removals/undo", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ undoToken }) });
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
export async function startProjectTrial(draft: ProjectDraft): Promise<{ runId: string; mode: "project-preview"; plan: ProjectPlan }> {
|
|
204
229
|
return json("/projects/trials", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
|
|
205
230
|
}
|
|
206
231
|
|
|
207
|
-
export async function applyProjectTrial(runId: string, generate: boolean): Promise<ProjectPlan & { setup: SetupInfo; run: { runId: string; mode: "write" } | null }> {
|
|
232
|
+
export async function applyProjectTrial(runId: string, generate: boolean): Promise<ProjectPlan & { undoToken: string; setup: SetupInfo; run: { runId: string; mode: "write" } | null }> {
|
|
208
233
|
return json(`/projects/trials/${encodeURIComponent(runId)}/apply`, { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ generate }) });
|
|
209
234
|
}
|
|
210
235
|
|
package/src/lib/motion.test.ts
CHANGED
|
@@ -102,10 +102,12 @@ describe("presence", () => {
|
|
|
102
102
|
expect(rise.initial.y).toBe(8);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
it("a page
|
|
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(
|
|
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", () => {
|
package/src/lib/motion.tsx
CHANGED
|
@@ -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
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Canonical public pages for Portolan itself, not the catalog being viewed. */
|
|
2
|
+
export const PRODUCT_REPOSITORY =
|
|
3
|
+
"https://github.com/shortlink-org/portolan";
|
|
4
|
+
|
|
5
|
+
export const PRODUCT_README = `${PRODUCT_REPOSITORY}#readme`;
|
|
6
|
+
export const PRODUCT_ISSUES = `${PRODUCT_REPOSITORY}/issues`;
|
|
7
|
+
export const PRODUCT_LICENSE = `${PRODUCT_REPOSITORY}/blob/main/LICENSE`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
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: [
|
|
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
|
-
|
|
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".
|
|
6
|
-
//
|
|
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 {
|
|
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
|
|
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
|
+
}
|
package/src/lib/queries.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { Catalog } from "../catalog";
|
|
|
16
16
|
import { diffCatalogs } from "./catalog-diff";
|
|
17
17
|
import type { Change } from "./catalog-diff";
|
|
18
18
|
import { findRef } from "./forge-refs";
|
|
19
|
-
import { listForgeRefs, loadForgeCatalog } from "./github-catalog";
|
|
19
|
+
import { listForgeRefs, loadForgeCatalog, loadForgeCommit } from "./github-catalog";
|
|
20
20
|
import type { ForgeRef, ForgeRepo } from "./github-catalog";
|
|
21
21
|
import { localStatus } from "./local-api";
|
|
22
22
|
import { loadSourceCode } from "./source-code";
|
|
@@ -30,6 +30,8 @@ export const forgeKeys = {
|
|
|
30
30
|
["forge", "refs", repo?.provider ?? "", repo?.webUrl ?? "", token] as const,
|
|
31
31
|
comparison: (repo: ForgeRepo | null, token: string, baseSha: string, headSha: string) =>
|
|
32
32
|
["forge", "comparison", repo?.provider ?? "", repo?.webUrl ?? "", baseSha, headSha, token] as const,
|
|
33
|
+
commit: (repo: ForgeRepo | null, token: string, sha: string) =>
|
|
34
|
+
["forge", "commit", repo?.provider ?? "", repo?.webUrl ?? "", sha, token] as const,
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
export const sourceKeys = {
|
|
@@ -56,6 +58,19 @@ export function forgeRefsQuery(repo: ForgeRepo | null, token: string) {
|
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
/** One immutable commit, fetched only when its hover card is open. */
|
|
62
|
+
export function forgeCommitQuery(repo: ForgeRepo | null, token: string, sha: string) {
|
|
63
|
+
return queryOptions({
|
|
64
|
+
queryKey: forgeKeys.commit(repo, token, sha),
|
|
65
|
+
queryFn: () => {
|
|
66
|
+
if (!repo) throw new Error("No forge repository is configured.");
|
|
67
|
+
return loadForgeCommit(repo, sha, { token });
|
|
68
|
+
},
|
|
69
|
+
enabled: repo !== null && Boolean(sha),
|
|
70
|
+
staleTime: Infinity,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
/**
|
|
60
75
|
* One side of a comparison: the name a reader typed, the commit it resolves
|
|
61
76
|
* to, and the catalog itself when the side is the one this build was made
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readmeAssetHref } from "./readme-assets";
|
|
3
|
+
|
|
4
|
+
describe("readmeAssetHref", () => {
|
|
5
|
+
it("mounts README-relative files below the configured site base", () => {
|
|
6
|
+
expect(readmeAssetHref("vendor/repos/acme/shop", "./docs/example.png", "/catalog/")).toBe(
|
|
7
|
+
"/catalog/portolan-assets/vendor/repos/acme/shop/docs/example.png",
|
|
8
|
+
);
|
|
9
|
+
expect(readmeAssetHref("services/api", "docs/file.json#example", "/")).toBe(
|
|
10
|
+
"/portolan-assets/services/api/docs/file.json#example",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("leaves remote, root-relative and escaping references alone", () => {
|
|
15
|
+
expect(readmeAssetHref("services/api", "https://example.com/image.png")).toBeNull();
|
|
16
|
+
expect(readmeAssetHref("services/api", "/logo.svg")).toBeNull();
|
|
17
|
+
expect(readmeAssetHref("services/api", "../secret.png")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Turn a README-relative file into the public asset staged beside the app. */
|
|
2
|
+
export function readmeAssetHref(
|
|
3
|
+
root: string,
|
|
4
|
+
target: string,
|
|
5
|
+
base = "/",
|
|
6
|
+
): string | null {
|
|
7
|
+
const value = target.trim();
|
|
8
|
+
if (
|
|
9
|
+
!value ||
|
|
10
|
+
value.startsWith("#") ||
|
|
11
|
+
value.startsWith("/") ||
|
|
12
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
|
|
13
|
+
) return null;
|
|
14
|
+
|
|
15
|
+
const suffixAt = value.search(/[?#]/);
|
|
16
|
+
const pathname = suffixAt < 0 ? value : value.slice(0, suffixAt);
|
|
17
|
+
const suffix = suffixAt < 0 ? "" : value.slice(suffixAt);
|
|
18
|
+
const rootParts = root.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
19
|
+
const parts = [...rootParts];
|
|
20
|
+
for (const part of pathname.replaceAll("\\", "/").split("/")) {
|
|
21
|
+
if (!part || part === ".") continue;
|
|
22
|
+
if (part === "..") {
|
|
23
|
+
if (parts.length === rootParts.length) return null;
|
|
24
|
+
parts.pop();
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
try { parts.push(decodeURIComponent(part)); }
|
|
28
|
+
catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
if (parts.length === rootParts.length) return null;
|
|
31
|
+
|
|
32
|
+
const prefix = base.endsWith("/") ? base : `${base}/`;
|
|
33
|
+
return `${prefix}portolan-assets/${parts.map((part) => encodeURIComponent(part)).join("/")}${suffix}`;
|
|
34
|
+
}
|
|
@@ -91,6 +91,13 @@ describe("publicSetupFrom", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it("publishes the deployable components of one repository", () => {
|
|
95
|
+
const setup = publicSetupFrom({
|
|
96
|
+
projects: [{ id: "platform", name: "Platform", root: "platform", group: "platform", components: ["api", "billing", "book", "user"] }],
|
|
97
|
+
});
|
|
98
|
+
expect(setup.projects[0]?.components).toEqual(["api", "billing", "book", "user"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
94
101
|
it("claims the sandbox only for a plugin that declares a module", () => {
|
|
95
102
|
const setup = publicSetupFrom({
|
|
96
103
|
plugins: [
|