@shortlink-org/portolan 0.2.2 → 0.2.4
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/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- 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/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -22,6 +22,26 @@ const GENERATED = "# Generated by Portolan. Preview changes in Settings before u
|
|
|
22
22
|
const GITLAB_START = "# >>> Portolan delivery preset >>>";
|
|
23
23
|
const GITLAB_END = "# <<< Portolan delivery preset <<<";
|
|
24
24
|
const PROVIDERS = new Set(["github", "gitlab"]);
|
|
25
|
+
const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
|
|
26
|
+
const DEFAULT_FEATURES = ["check", "pages"];
|
|
27
|
+
const FEATURE_DETAILS = {
|
|
28
|
+
check: {
|
|
29
|
+
label: "Architecture check",
|
|
30
|
+
description: "Fail when committed architecture files are stale.",
|
|
31
|
+
},
|
|
32
|
+
diff: {
|
|
33
|
+
label: "Pull-request architecture diff",
|
|
34
|
+
description: "Post a sticky architecture summary where review happens.",
|
|
35
|
+
},
|
|
36
|
+
sarif: {
|
|
37
|
+
label: "SARIF annotations",
|
|
38
|
+
description: "Annotate changed architecture in GitHub's checks and code-scanning views.",
|
|
39
|
+
},
|
|
40
|
+
pages: {
|
|
41
|
+
label: "Static catalog publishing",
|
|
42
|
+
description: "Build and publish the catalog after changes reach the default branch.",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
25
45
|
|
|
26
46
|
function exists(path) {
|
|
27
47
|
try { lstatSync(path); return true; } catch { return false; }
|
|
@@ -153,9 +173,58 @@ jobs:
|
|
|
153
173
|
`;
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
"
|
|
176
|
+
function githubDiff({ pages, sarif }) {
|
|
177
|
+
const site = pages
|
|
178
|
+
? "\n site: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}"
|
|
179
|
+
: "";
|
|
180
|
+
const sarifInput = sarif ? "\n sarif-output: .portolan/diff.sarif" : "";
|
|
181
|
+
const sarifPermission = sarif ? "\n security-events: write # annotations in the checks tab" : "";
|
|
182
|
+
const upload = sarif
|
|
183
|
+
? `
|
|
184
|
+
- name: Annotate the checks tab
|
|
185
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
186
|
+
with:
|
|
187
|
+
sarif_file: .portolan/diff.sarif
|
|
188
|
+
category: architecture-diff
|
|
189
|
+
# Fork pull requests have a read-only token; the Markdown summary still works.
|
|
190
|
+
continue-on-error: true
|
|
191
|
+
`
|
|
192
|
+
: "";
|
|
193
|
+
return `${GENERATED}
|
|
194
|
+
name: Portolan architecture diff
|
|
195
|
+
|
|
196
|
+
on:
|
|
197
|
+
pull_request:
|
|
198
|
+
|
|
199
|
+
permissions:
|
|
200
|
+
contents: read
|
|
201
|
+
pull-requests: write${sarifPermission}
|
|
202
|
+
|
|
203
|
+
concurrency:
|
|
204
|
+
group: portolan-diff-\${{ github.ref }}
|
|
205
|
+
cancel-in-progress: true
|
|
206
|
+
|
|
207
|
+
jobs:
|
|
208
|
+
describe:
|
|
209
|
+
runs-on: ubuntu-latest
|
|
210
|
+
steps:
|
|
211
|
+
- uses: actions/checkout@v7
|
|
212
|
+
with:
|
|
213
|
+
fetch-depth: 0
|
|
214
|
+
- uses: shortlink-org/portolan@${VERSION}
|
|
215
|
+
env:
|
|
216
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
217
|
+
with:
|
|
218
|
+
command: diff
|
|
219
|
+
version: ${VERSION}
|
|
220
|
+
base-ref: origin/\${{ github.base_ref }}
|
|
221
|
+
head-ref: \${{ github.head_ref }}
|
|
222
|
+
comment: true${site}${sarifInput}
|
|
223
|
+
${upload}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function gitlabCheck() {
|
|
227
|
+
return `"portolan:check":
|
|
159
228
|
stage: .pre
|
|
160
229
|
image:
|
|
161
230
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
@@ -167,8 +236,30 @@ function gitlabBlock() {
|
|
|
167
236
|
rules:
|
|
168
237
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
169
238
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
239
|
+
`;
|
|
240
|
+
}
|
|
170
241
|
|
|
171
|
-
|
|
242
|
+
function gitlabDiff({ pages }) {
|
|
243
|
+
const site = pages ? ' --site "$CI_PAGES_URL"' : "";
|
|
244
|
+
return `"portolan:review":
|
|
245
|
+
stage: .pre
|
|
246
|
+
image:
|
|
247
|
+
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
248
|
+
entrypoint: [""]
|
|
249
|
+
variables:
|
|
250
|
+
GIT_DEPTH: "0"
|
|
251
|
+
script:
|
|
252
|
+
- portolan diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"${site} --output .portolan/diff.md
|
|
253
|
+
- cat .portolan/diff.md
|
|
254
|
+
- portolan comment .portolan/diff.md
|
|
255
|
+
allow_failure: true
|
|
256
|
+
rules:
|
|
257
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function gitlabPages() {
|
|
262
|
+
return `"portolan:pages":
|
|
172
263
|
stage: .post
|
|
173
264
|
image:
|
|
174
265
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
@@ -182,11 +273,19 @@ function gitlabBlock() {
|
|
|
182
273
|
publish: dist
|
|
183
274
|
rules:
|
|
184
275
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
185
|
-
${GITLAB_END}
|
|
186
276
|
`;
|
|
187
277
|
}
|
|
188
278
|
|
|
189
|
-
function
|
|
279
|
+
function gitlabBlock(features) {
|
|
280
|
+
const jobs = [];
|
|
281
|
+
if (features.has("check")) jobs.push(gitlabCheck());
|
|
282
|
+
if (features.has("diff")) jobs.push(gitlabDiff({ pages: features.has("pages") }));
|
|
283
|
+
if (features.has("pages")) jobs.push(gitlabPages());
|
|
284
|
+
if (jobs.length === 0) return "";
|
|
285
|
+
return `${GITLAB_START}\n${jobs.join("\n")}${GITLAB_END}\n`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mergeGitlab(existing, features) {
|
|
190
289
|
const start = existing.indexOf(GITLAB_START);
|
|
191
290
|
const end = existing.indexOf(GITLAB_END);
|
|
192
291
|
if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
|
|
@@ -194,12 +293,17 @@ function mergeGitlab(existing) {
|
|
|
194
293
|
}
|
|
195
294
|
if (start !== -1) {
|
|
196
295
|
const after = end + GITLAB_END.length;
|
|
197
|
-
|
|
296
|
+
const replacement = gitlabBlock(features).trimEnd();
|
|
297
|
+
const content = `${existing.slice(0, start)}${replacement}${existing.slice(after)}`
|
|
298
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
299
|
+
.replace(/^\n+|\n+$/g, "");
|
|
300
|
+
return { content: content ? `${content}\n` : "" };
|
|
198
301
|
}
|
|
199
|
-
if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
|
|
302
|
+
if (/^[ \t]*["']?portolan:(?:check|review|pages)["']?\s*:/m.test(existing)) {
|
|
200
303
|
return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
|
|
201
304
|
}
|
|
202
|
-
|
|
305
|
+
const block = gitlabBlock(features);
|
|
306
|
+
return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
|
|
203
307
|
}
|
|
204
308
|
|
|
205
309
|
function textDiff(path, before, after) {
|
|
@@ -251,6 +355,16 @@ function readTarget(workspace, path) {
|
|
|
251
355
|
function githubFile(workspace, path, content) {
|
|
252
356
|
const current = readTarget(workspace, path);
|
|
253
357
|
const managed = current.safe && current.content.startsWith(GENERATED);
|
|
358
|
+
if (content === null) {
|
|
359
|
+
if (!current.exists || !managed) return null;
|
|
360
|
+
return {
|
|
361
|
+
path,
|
|
362
|
+
status: "removed",
|
|
363
|
+
diff: textDiff(path, current.content, ""),
|
|
364
|
+
current: current.content,
|
|
365
|
+
next: "",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
254
368
|
const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
|
|
255
369
|
return {
|
|
256
370
|
path,
|
|
@@ -262,39 +376,102 @@ function githubFile(workspace, path, content) {
|
|
|
262
376
|
};
|
|
263
377
|
}
|
|
264
378
|
|
|
265
|
-
function gitlabFile(workspace) {
|
|
379
|
+
function gitlabFile(workspace, features) {
|
|
266
380
|
const path = ".gitlab-ci.yml";
|
|
267
381
|
const current = readTarget(workspace, path);
|
|
268
382
|
if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
|
|
269
|
-
const merged = mergeGitlab(current.content);
|
|
383
|
+
const merged = mergeGitlab(current.content, features);
|
|
270
384
|
if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
|
|
271
|
-
const status =
|
|
385
|
+
const status = current.content === merged.content ? "unchanged" : !current.exists ? "added" : "changed";
|
|
272
386
|
return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
|
|
273
387
|
}
|
|
274
388
|
|
|
275
|
-
function revisionFor(provider, files) {
|
|
389
|
+
function revisionFor(provider, features, files) {
|
|
276
390
|
const hash = createHash("sha256").update(provider);
|
|
391
|
+
hash.update("\0"); hash.update([...features].sort().join(","));
|
|
277
392
|
for (const file of files) {
|
|
278
393
|
hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
|
|
279
394
|
}
|
|
280
395
|
return hash.digest("hex");
|
|
281
396
|
}
|
|
282
397
|
|
|
283
|
-
|
|
398
|
+
function deliveryRequest(request) {
|
|
399
|
+
return typeof request === "string" || request == null ? { provider: request ?? undefined } : request;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function installedFeatures(workspace, provider) {
|
|
403
|
+
const selected = new Set();
|
|
404
|
+
if (provider === "github") {
|
|
405
|
+
const check = readTarget(workspace, ".github/workflows/portolan-check.yml");
|
|
406
|
+
const review = readTarget(workspace, ".github/workflows/portolan-review.yml");
|
|
407
|
+
const pages = readTarget(workspace, ".github/workflows/portolan-pages.yml");
|
|
408
|
+
if (check.safe && check.content.startsWith(GENERATED)) selected.add("check");
|
|
409
|
+
if (review.safe && review.content.startsWith(GENERATED)) {
|
|
410
|
+
selected.add("diff");
|
|
411
|
+
if (review.content.includes("sarif-output:")) selected.add("sarif");
|
|
412
|
+
}
|
|
413
|
+
if (pages.safe && pages.content.startsWith(GENERATED)) selected.add("pages");
|
|
414
|
+
return selected;
|
|
415
|
+
}
|
|
416
|
+
const pipeline = readTarget(workspace, ".gitlab-ci.yml");
|
|
417
|
+
if (!pipeline.safe || !pipeline.content.includes(GITLAB_START)) return selected;
|
|
418
|
+
const managed = pipeline.content.slice(
|
|
419
|
+
pipeline.content.indexOf(GITLAB_START),
|
|
420
|
+
pipeline.content.indexOf(GITLAB_END) + GITLAB_END.length,
|
|
421
|
+
);
|
|
422
|
+
if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
|
|
423
|
+
if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
|
|
424
|
+
if (/^[ \t]*["']?portolan:pages["']?\s*:/m.test(managed)) selected.add("pages");
|
|
425
|
+
return selected;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function selectedFeatures(workspace, request, provider) {
|
|
429
|
+
const installed = request.features == null ? installedFeatures(workspace, provider) : null;
|
|
430
|
+
const raw = request.features == null
|
|
431
|
+
? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
|
|
432
|
+
: Array.isArray(request.features)
|
|
433
|
+
? request.features
|
|
434
|
+
: String(request.features).split(",").filter(Boolean);
|
|
435
|
+
const selected = new Set(raw);
|
|
436
|
+
for (const feature of selected) {
|
|
437
|
+
if (!FEATURE_IDS.includes(feature)) throw new Error(`Unknown delivery feature ${feature}.`);
|
|
438
|
+
}
|
|
439
|
+
if (selected.has("sarif")) selected.add("diff");
|
|
440
|
+
if (provider !== "github" && selected.has("sarif")) {
|
|
441
|
+
throw new Error("SARIF annotations are currently available only for GitHub.");
|
|
442
|
+
}
|
|
443
|
+
return selected;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function featureOptions(provider, selected) {
|
|
447
|
+
return FEATURE_IDS.map((id) => ({
|
|
448
|
+
id,
|
|
449
|
+
...FEATURE_DETAILS[id],
|
|
450
|
+
selected: selected.has(id),
|
|
451
|
+
available: id !== "sarif" || provider === "github",
|
|
452
|
+
requires: id === "sarif" ? ["diff"] : [],
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function planDeliveryPreset(workspace, requested = {}) {
|
|
457
|
+
const request = deliveryRequest(requested);
|
|
284
458
|
const detected = detectDeliveryForge(workspace);
|
|
285
|
-
const provider =
|
|
459
|
+
const provider = request.provider || detected.provider || "github";
|
|
286
460
|
if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
|
|
461
|
+
const features = selectedFeatures(workspace, request, provider);
|
|
287
462
|
const files = provider === "github"
|
|
288
463
|
? [
|
|
289
|
-
githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
|
|
290
|
-
githubFile(workspace, ".github/workflows/portolan-
|
|
291
|
-
|
|
292
|
-
|
|
464
|
+
githubFile(workspace, ".github/workflows/portolan-check.yml", features.has("check") ? githubCheck(detected.defaultBranch) : null),
|
|
465
|
+
githubFile(workspace, ".github/workflows/portolan-review.yml", features.has("diff") ? githubDiff({ pages: features.has("pages"), sarif: features.has("sarif") }) : null),
|
|
466
|
+
githubFile(workspace, ".github/workflows/portolan-pages.yml", features.has("pages") ? githubPages(detected.defaultBranch) : null),
|
|
467
|
+
].filter(Boolean)
|
|
468
|
+
: [gitlabFile(workspace, features)];
|
|
293
469
|
const status = files.some((file) => file.status === "conflict")
|
|
294
470
|
? "conflict"
|
|
295
471
|
: files.every((file) => file.status === "unchanged")
|
|
296
472
|
? "installed"
|
|
297
|
-
: files.some((file) =>
|
|
473
|
+
: files.some((file) => ["removed", "unchanged"].includes(file.status))
|
|
474
|
+
|| files.some((file) => file.status === "changed" && (provider !== "gitlab" || file.current.includes(GITLAB_START)))
|
|
298
475
|
? "update"
|
|
299
476
|
: "available";
|
|
300
477
|
return {
|
|
@@ -304,8 +481,8 @@ export function planDeliveryPreset(workspace, requestedProvider) {
|
|
|
304
481
|
repository: detected.repository,
|
|
305
482
|
defaultBranch: detected.defaultBranch,
|
|
306
483
|
status,
|
|
307
|
-
revision: revisionFor(provider, files),
|
|
308
|
-
features:
|
|
484
|
+
revision: revisionFor(provider, features, files),
|
|
485
|
+
features: featureOptions(provider, features),
|
|
309
486
|
files,
|
|
310
487
|
};
|
|
311
488
|
}
|
|
@@ -315,7 +492,8 @@ export function publicDeliveryPreset(plan) {
|
|
|
315
492
|
}
|
|
316
493
|
|
|
317
494
|
export function installDeliveryPreset(workspace, request) {
|
|
318
|
-
const
|
|
495
|
+
const selection = { provider: request.provider, features: request.features };
|
|
496
|
+
const plan = planDeliveryPreset(workspace, selection);
|
|
319
497
|
if (!request.revision || request.revision !== plan.revision) {
|
|
320
498
|
const error = new Error("Files changed after this preset preview. Preview it again.");
|
|
321
499
|
error.status = 409;
|
|
@@ -339,6 +517,11 @@ export function installDeliveryPreset(workspace, request) {
|
|
|
339
517
|
for (const file of plan.files) {
|
|
340
518
|
if (file.status === "unchanged") continue;
|
|
341
519
|
const target = resolve(workspace, file.path);
|
|
520
|
+
if (file.status === "removed") {
|
|
521
|
+
rmSync(target);
|
|
522
|
+
written.push(file.path);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
342
525
|
mkdirSync(dirname(target), { recursive: true });
|
|
343
526
|
const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
|
|
344
527
|
try {
|
|
@@ -349,5 +532,5 @@ export function installDeliveryPreset(workspace, request) {
|
|
|
349
532
|
}
|
|
350
533
|
written.push(file.path);
|
|
351
534
|
}
|
|
352
|
-
return { ...publicDeliveryPreset(planDeliveryPreset(workspace,
|
|
535
|
+
return { ...publicDeliveryPreset(planDeliveryPreset(workspace, selection)), written };
|
|
353
536
|
}
|
package/scripts/diff.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { enrichCatalog } from "../src/enrich.ts";
|
|
|
29
29
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
30
30
|
import { diffCatalogs, SEVERITIES } from "../src/lib/catalog-diff.ts";
|
|
31
31
|
import { loadCatalog } from "./catalog-sources.mjs";
|
|
32
|
+
import { readManifestText } from "./manifest.mjs";
|
|
32
33
|
|
|
33
34
|
const HEADINGS = {
|
|
34
35
|
breaking: "Breaking",
|
|
@@ -142,7 +143,10 @@ function defaultBase() {
|
|
|
142
143
|
|
|
143
144
|
/** The merged, enriched, validated catalog as it stood at `ref`. */
|
|
144
145
|
function catalogAt(ref) {
|
|
145
|
-
const manifest =
|
|
146
|
+
const manifest = readManifestText(
|
|
147
|
+
git(["show", ref + ":portolan.json"]),
|
|
148
|
+
`${ref}:portolan.json`,
|
|
149
|
+
);
|
|
146
150
|
const patterns = (manifest.sources ?? []).map(globToRegExp);
|
|
147
151
|
|
|
148
152
|
const paths = git(["ls-tree", "-r", "--name-only", ref])
|
package/scripts/gen-likec4.mjs
CHANGED
|
@@ -590,10 +590,11 @@ function containerPredicates(pairs, carried, indent) {
|
|
|
590
590
|
* in a `break` frame, which is what a sequence diagram calls a branch that
|
|
591
591
|
* leaves the flow rather than rejoining it.
|
|
592
592
|
*/
|
|
593
|
-
// ---
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
593
|
+
// --- request and response edges (mirrors src/flow/answers.ts) --------------
|
|
594
|
+
// A unary RPC is two messages, not one long edge label. When composition has
|
|
595
|
+
// already materialised a response step, that step is the return. Otherwise we
|
|
596
|
+
// draw the contract response: immediately for a nested call, and at the end of
|
|
597
|
+
// the flow for the actor request that opened it.
|
|
597
598
|
const methodOf = new Map();
|
|
598
599
|
const serviceById = new Map();
|
|
599
600
|
for (const context of catalog.contexts) {
|
|
@@ -615,29 +616,43 @@ for (const external of catalog.externals ?? []) {
|
|
|
615
616
|
}
|
|
616
617
|
}
|
|
617
618
|
|
|
618
|
-
function
|
|
619
|
-
if (step.kind !== "rpc") return
|
|
620
|
-
if (step.ref) return methodOf.get(step.ref)
|
|
619
|
+
function contractOf(step) {
|
|
620
|
+
if (step.kind !== "rpc") return null;
|
|
621
|
+
if (step.ref) return methodOf.get(step.ref) ?? null;
|
|
621
622
|
const service = serviceById.get(step.to);
|
|
622
|
-
if (!service || !step.label) return
|
|
623
|
+
if (!service || !step.label) return null;
|
|
623
624
|
for (const provided of service.provides) {
|
|
624
625
|
const found = provided.methods.find((m) => m.name === step.label);
|
|
625
|
-
if (found) return found
|
|
626
|
+
if (found) return found;
|
|
626
627
|
}
|
|
627
|
-
return
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function emitSyntheticResponse(out, indent, node, response) {
|
|
632
|
+
out.push(
|
|
633
|
+
`${indent}${participantRef(node.to)} -> ${participantRef(node.from)} ${q(response)} {`,
|
|
634
|
+
);
|
|
635
|
+
out.push(
|
|
636
|
+
`${indent} color ${node.status} line dashed head ${KIND_HEAD.response}`,
|
|
637
|
+
);
|
|
638
|
+
out.push(`${indent}}`);
|
|
628
639
|
}
|
|
629
640
|
|
|
630
|
-
function emitSteps(nodes, out, indent, replied) {
|
|
641
|
+
function emitSteps(nodes, out, indent, replied, deferredResponseId) {
|
|
631
642
|
for (const node of nodes) {
|
|
632
643
|
if (node.type === "step") {
|
|
633
|
-
const
|
|
644
|
+
const contract = contractOf(node);
|
|
645
|
+
const request = contract?.request ?? "";
|
|
646
|
+
const response = replied.has(node.id) ? "" : (contract?.response ?? "");
|
|
634
647
|
const storeLabel =
|
|
635
648
|
node.storeAccess?.operation && node.storeAccess?.keyspace
|
|
636
649
|
? `${node.storeAccess.operation.toUpperCase()} ${node.storeAccess.keyspace}`
|
|
637
650
|
: "";
|
|
638
651
|
const label =
|
|
639
|
-
|
|
640
|
-
(
|
|
652
|
+
storeLabel ||
|
|
653
|
+
(node.kind === "rpc" && request
|
|
654
|
+
? request
|
|
655
|
+
: node.label || node.ref || node.kind);
|
|
641
656
|
const attrs = [
|
|
642
657
|
`color ${node.http?.outcome === "error" ? "response_error" : node.status}`,
|
|
643
658
|
`line ${node.kind === "response" ? "dashed" : "solid"}`,
|
|
@@ -656,18 +671,21 @@ function emitSteps(nodes, out, indent, replied) {
|
|
|
656
671
|
if (notes.length > 0)
|
|
657
672
|
out.push(`${indent} notes ${q(notes.join(" — "))}`);
|
|
658
673
|
out.push(`${indent}}`);
|
|
674
|
+
if (response && node.id !== deferredResponseId) {
|
|
675
|
+
emitSyntheticResponse(out, indent, node, response);
|
|
676
|
+
}
|
|
659
677
|
continue;
|
|
660
678
|
}
|
|
661
679
|
if (node.type === "parallel") {
|
|
662
680
|
out.push(`${indent}par ${node.title ? `${q(node.title)} ` : ""}{`);
|
|
663
681
|
for (const branch of node.branches)
|
|
664
|
-
emitSteps(branch, out, `${indent} `, replied);
|
|
682
|
+
emitSteps(branch, out, `${indent} `, replied, deferredResponseId);
|
|
665
683
|
out.push(`${indent}}`);
|
|
666
684
|
continue;
|
|
667
685
|
}
|
|
668
686
|
if (node.type === "loop") {
|
|
669
687
|
out.push(`${indent}loop ${q(node.title)} {`);
|
|
670
|
-
emitSteps(node.steps, out, `${indent} `, replied);
|
|
688
|
+
emitSteps(node.steps, out, `${indent} `, replied, deferredResponseId);
|
|
671
689
|
out.push(`${indent}}`);
|
|
672
690
|
continue;
|
|
673
691
|
}
|
|
@@ -678,10 +696,22 @@ function emitSteps(nodes, out, indent, replied) {
|
|
|
678
696
|
out.push(`${indent} ${keyword} ${q(branch.title)} {`);
|
|
679
697
|
if (branch.terminal) {
|
|
680
698
|
out.push(`${indent} break 'ends the flow' {`);
|
|
681
|
-
emitSteps(
|
|
699
|
+
emitSteps(
|
|
700
|
+
branch.steps,
|
|
701
|
+
out,
|
|
702
|
+
`${indent} `,
|
|
703
|
+
replied,
|
|
704
|
+
deferredResponseId,
|
|
705
|
+
);
|
|
682
706
|
out.push(`${indent} }`);
|
|
683
707
|
} else {
|
|
684
|
-
emitSteps(
|
|
708
|
+
emitSteps(
|
|
709
|
+
branch.steps,
|
|
710
|
+
out,
|
|
711
|
+
`${indent} `,
|
|
712
|
+
replied,
|
|
713
|
+
deferredResponseId,
|
|
714
|
+
);
|
|
685
715
|
}
|
|
686
716
|
out.push(`${indent} }`);
|
|
687
717
|
});
|
|
@@ -961,16 +991,34 @@ views.push("");
|
|
|
961
991
|
for (const flow of catalog.flows) {
|
|
962
992
|
const contexts = new Map(flow.participants.map((p) => [p.id, p.context]));
|
|
963
993
|
const contextOf = (id) => contexts.get(id) ?? null;
|
|
994
|
+
const actorIds = new Set(
|
|
995
|
+
flow.participants.filter((p) => p.kind === "actor").map((p) => p.id),
|
|
996
|
+
);
|
|
964
997
|
const replied = new Set();
|
|
965
998
|
walkFlowSteps(flow.steps, (step) => {
|
|
966
999
|
if (step.kind === "response" && step.replyTo) replied.add(step.replyTo);
|
|
967
1000
|
});
|
|
1001
|
+
let deferredResponse = null;
|
|
1002
|
+
walkFlowSteps(flow.steps, (step) => {
|
|
1003
|
+
if (deferredResponse || step.kind !== "rpc" || !actorIds.has(step.from))
|
|
1004
|
+
return;
|
|
1005
|
+
const response = replied.has(step.id) ? "" : (contractOf(step)?.response ?? "");
|
|
1006
|
+
if (response) deferredResponse = { step, response };
|
|
1007
|
+
});
|
|
968
1008
|
|
|
969
1009
|
views.push(` dynamic view ${flowViewId(flow)} {`);
|
|
970
1010
|
views.push(` title ${q(flow.name)}`);
|
|
971
1011
|
views.push(` description ${q(flow.summary)}`);
|
|
972
1012
|
const body = [];
|
|
973
|
-
emitSteps(flow.steps, body, " ", replied);
|
|
1013
|
+
emitSteps(flow.steps, body, " ", replied, deferredResponse?.step.id);
|
|
1014
|
+
if (deferredResponse) {
|
|
1015
|
+
emitSyntheticResponse(
|
|
1016
|
+
body,
|
|
1017
|
+
" ",
|
|
1018
|
+
deferredResponse.step,
|
|
1019
|
+
deferredResponse.response,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
974
1022
|
views.push(...body);
|
|
975
1023
|
views.push(" }");
|
|
976
1024
|
views.push("");
|
|
@@ -979,7 +1027,17 @@ for (const flow of catalog.flows) {
|
|
|
979
1027
|
views.push(` dynamic view ${flowCrossViewId(flow)} {`);
|
|
980
1028
|
views.push(` title ${q(`${flow.name} — crossings only`)}`);
|
|
981
1029
|
const crossBody = [];
|
|
982
|
-
|
|
1030
|
+
const crossStepIds = new Set();
|
|
1031
|
+
walkFlowSteps(cross, (step) => crossStepIds.add(step.id));
|
|
1032
|
+
emitSteps(cross, crossBody, " ", replied, deferredResponse?.step.id);
|
|
1033
|
+
if (deferredResponse && crossStepIds.has(deferredResponse.step.id)) {
|
|
1034
|
+
emitSyntheticResponse(
|
|
1035
|
+
crossBody,
|
|
1036
|
+
" ",
|
|
1037
|
+
deferredResponse.step,
|
|
1038
|
+
deferredResponse.response,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
983
1041
|
if (crossBody.length === 0) {
|
|
984
1042
|
// A flow with no crossing at all still needs a renderable view.
|
|
985
1043
|
const first = flow.participants[0];
|
|
@@ -164,11 +164,34 @@ describe("the LikeC4 generator", () => {
|
|
|
164
164
|
},
|
|
165
165
|
],
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
id: "flow.get-book-contract",
|
|
169
|
+
slug: "get-book-contract",
|
|
170
|
+
name: "Get book contract",
|
|
171
|
+
summary: "",
|
|
172
|
+
owner: "demo",
|
|
173
|
+
participants: [
|
|
174
|
+
{ id: "demo.api", kind: "service", context: "demo" },
|
|
175
|
+
{ id: "demo.book", kind: "service", context: "demo" },
|
|
176
|
+
],
|
|
177
|
+
steps: [
|
|
178
|
+
{
|
|
179
|
+
type: "step",
|
|
180
|
+
id: "request",
|
|
181
|
+
from: "demo.api",
|
|
182
|
+
to: "demo.book",
|
|
183
|
+
kind: "rpc",
|
|
184
|
+
ref: "book.v1.Book/Get",
|
|
185
|
+
label: "Get",
|
|
186
|
+
status: "declared",
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
167
190
|
],
|
|
168
191
|
});
|
|
169
192
|
|
|
170
193
|
expect(views).toContain(
|
|
171
|
-
"demo.api -> demo.book '
|
|
194
|
+
"demo.api -> demo.book 'GetRequest' {\n color declared line solid head normal",
|
|
172
195
|
);
|
|
173
196
|
expect(views).toContain(
|
|
174
197
|
"demo.book -> demo.api 'GetResponse' {\n color declared line dashed head normal",
|
|
@@ -177,7 +200,7 @@ describe("the LikeC4 generator", () => {
|
|
|
177
200
|
"demo.book -> demo.api '500 · Error' {\n color response_error line dashed head normal",
|
|
178
201
|
);
|
|
179
202
|
expect(spec).toContain("color response_error #b7646b");
|
|
180
|
-
expect(views).not.toContain("
|
|
203
|
+
expect(views).not.toContain("GetRequest → GetResponse");
|
|
181
204
|
});
|
|
182
205
|
|
|
183
206
|
it("treats dots in a root participant id as data, not containment", () => {
|