@shortlink-org/portolan 0.3.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.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/src/lib/shape.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
} from "../catalog";
|
|
27
27
|
import { aggregateBlocks, blockFields, enumsOf } from "../catalog";
|
|
28
28
|
import type { DefUsage } from "./derive";
|
|
29
|
+
import { ruleMarks } from "./rules";
|
|
29
30
|
|
|
30
31
|
/** How many of the base type a field holds. */
|
|
31
32
|
export type Cardinality = "one" | "many" | "map";
|
|
@@ -336,13 +337,19 @@ export interface FieldChange {
|
|
|
336
337
|
change: Change;
|
|
337
338
|
/** The type the field had in the previous version, when it changed. */
|
|
338
339
|
from?: string;
|
|
340
|
+
/**
|
|
341
|
+
* The rules the field had in the previous version, as the marks read -
|
|
342
|
+
* "required, ≤ 8 chars" - when this version changed them; "" when it had
|
|
343
|
+
* none. The row itself shows what they are now.
|
|
344
|
+
*/
|
|
345
|
+
rulesFrom?: string;
|
|
339
346
|
}
|
|
340
347
|
|
|
341
348
|
/**
|
|
342
349
|
* What a version did to the schema: the fields it added, the ones whose
|
|
343
|
-
* type it changed, and - by name, since they are no longer in the
|
|
344
|
-
* own list - the ones it dropped. The first version changes
|
|
345
|
-
* is nothing before it to differ from.
|
|
350
|
+
* type or rules it changed, and - by name, since they are no longer in the
|
|
351
|
+
* version's own list - the ones it dropped. The first version changes
|
|
352
|
+
* nothing: there is nothing before it to differ from.
|
|
346
353
|
*/
|
|
347
354
|
export function schemaChanges(
|
|
348
355
|
event: Event,
|
|
@@ -360,9 +367,14 @@ export function schemaChanges(
|
|
|
360
367
|
const now = new Set(current.fields.map((f) => f.name));
|
|
361
368
|
for (const field of current.fields) {
|
|
362
369
|
const was = before.get(field.name);
|
|
363
|
-
if (!was)
|
|
364
|
-
|
|
365
|
-
|
|
370
|
+
if (!was) {
|
|
371
|
+
byField.set(field.name, { change: "new" });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const change: FieldChange = { change: "changed" };
|
|
375
|
+
if (was.type !== field.type) change.from = was.type;
|
|
376
|
+
if (!sameRules(was, field)) change.rulesFrom = ruleMarks(was).map((m) => m.text).join(", ");
|
|
377
|
+
if (change.from !== undefined || change.rulesFrom !== undefined) byField.set(field.name, change);
|
|
366
378
|
}
|
|
367
379
|
for (const field of prev.fields) {
|
|
368
380
|
if (!now.has(field.name)) removed.push(field);
|
|
@@ -370,6 +382,18 @@ export function schemaChanges(
|
|
|
370
382
|
return { byField, removed };
|
|
371
383
|
}
|
|
372
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Whether two versions of a field ask the same of its value. The order the
|
|
387
|
+
* rules are listed in is the source's, not a fact about the value, so it
|
|
388
|
+
* is not a difference.
|
|
389
|
+
*/
|
|
390
|
+
function sameRules(a: Field, b: Field): boolean {
|
|
391
|
+
if (Boolean(a.required) !== Boolean(b.required)) return false;
|
|
392
|
+
const key = (field: Field) =>
|
|
393
|
+
(field.rules ?? []).map((r) => `${r.name}=${r.value ?? ""}`).sort().join("\n");
|
|
394
|
+
return key(a) === key(b);
|
|
395
|
+
}
|
|
396
|
+
|
|
373
397
|
// ---------------------------------------------------------------------------
|
|
374
398
|
// An enum and a lifecycle.
|
|
375
399
|
// ---------------------------------------------------------------------------
|
package/src/lib/tech.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
siApachepulsar,
|
|
31
31
|
siArgo,
|
|
32
32
|
siAuth0,
|
|
33
|
+
siBackstage,
|
|
33
34
|
siBun,
|
|
34
35
|
siC,
|
|
35
36
|
siCelery,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
siClickhouse,
|
|
38
39
|
siClojure,
|
|
39
40
|
siCloudflare,
|
|
41
|
+
siConfluence,
|
|
40
42
|
siCockroachlabs,
|
|
41
43
|
siConsul,
|
|
42
44
|
siCouchbase,
|
|
@@ -93,7 +95,9 @@ import {
|
|
|
93
95
|
siLinkerd,
|
|
94
96
|
siLua,
|
|
95
97
|
siMariadb,
|
|
98
|
+
siMarkdown,
|
|
96
99
|
siMeilisearch,
|
|
100
|
+
siMermaid,
|
|
97
101
|
siMinio,
|
|
98
102
|
siMongodb,
|
|
99
103
|
siMqtt,
|
|
@@ -107,6 +111,7 @@ import {
|
|
|
107
111
|
siNginx,
|
|
108
112
|
siNodedotjs,
|
|
109
113
|
siNomad,
|
|
114
|
+
siNotion,
|
|
110
115
|
siNpm,
|
|
111
116
|
siOllama,
|
|
112
117
|
siOpenapiinitiative,
|
|
@@ -346,6 +351,11 @@ export const TECH_MARKS: readonly TechMark[] = [
|
|
|
346
351
|
mark(siJenkins),
|
|
347
352
|
mark(siArgo, "Argo CD", "ArgoCD"),
|
|
348
353
|
|
|
354
|
+
// Where a team writes. Not read off a manifest; the marks are for the
|
|
355
|
+
// reader-side integrations that open the wiki from a page.
|
|
356
|
+
mark(siConfluence),
|
|
357
|
+
mark(siNotion),
|
|
358
|
+
|
|
349
359
|
// Observability.
|
|
350
360
|
mark(siOpentelemetry, "otel"),
|
|
351
361
|
mark(siPrometheus),
|
|
@@ -374,6 +384,11 @@ export const TECH_MARKS: readonly TechMark[] = [
|
|
|
374
384
|
mark(siJest),
|
|
375
385
|
mark(siVitest),
|
|
376
386
|
mark(siCypress),
|
|
387
|
+
|
|
388
|
+
// What the generators write to.
|
|
389
|
+
mark(siMarkdown),
|
|
390
|
+
mark(siMermaid),
|
|
391
|
+
mark(siBackstage),
|
|
377
392
|
];
|
|
378
393
|
|
|
379
394
|
const BY_KEY: ReadonlyMap<string, TechGlyph> = (() => {
|
|
@@ -417,5 +432,6 @@ export const STORE_KIND_GLYPH: Record<StoreKind, TechGlyph | null> = {
|
|
|
417
432
|
clickhouse: techGlyph("clickhouse"),
|
|
418
433
|
// simple-icons was asked to drop Amazon's marks, S3's among them.
|
|
419
434
|
s3: null,
|
|
435
|
+
dynamodb: null,
|
|
420
436
|
other: null,
|
|
421
437
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { projectForFlow, projectsForFlow } from "./trace-project";
|
|
4
|
+
|
|
5
|
+
const projects = [
|
|
6
|
+
{ id: "auth", name: "Auth", root: "examples/auth", context: "auth", service: "auth" },
|
|
7
|
+
{ id: "shop-cart", name: "Cart", root: "examples/shop/cart", context: "shop", service: "cart" },
|
|
8
|
+
{ id: "shop-oms", name: "OMS", root: "examples/shop/oms", group: "shop", component: "oms" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const flow = (owner: string, ...services: string[]) => ({
|
|
12
|
+
owner,
|
|
13
|
+
participants: [
|
|
14
|
+
{ id: "client", kind: "actor" as const, context: null },
|
|
15
|
+
...services.map((id) => ({ id, kind: "service" as const, context: id.split(".")[0] ?? null })),
|
|
16
|
+
{ id: "bus", kind: "broker" as const, context: null },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("which project a recording of a flow belongs to", () => {
|
|
21
|
+
it("is the one project in the flow's context", () => {
|
|
22
|
+
expect(projectForFlow(projects, flow("auth", "auth.auth"))?.id).toBe("auth");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is the project whose service the flow runs through when the context has several", () => {
|
|
26
|
+
expect(projectForFlow(projects, flow("shop", "shop.oms", "shop.pricing"))?.id).toBe("shop-oms");
|
|
27
|
+
expect(projectsForFlow(projects, flow("shop", "shop.cart")).map((p) => p.id)).toEqual(["shop-cart", "shop-oms"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is nobody's when the context has no project, or two the flow runs through", () => {
|
|
31
|
+
expect(projectForFlow(projects, flow("payments", "payments.ledger"))).toBeNull();
|
|
32
|
+
expect(projectForFlow(projects, flow("shop", "shop.cart", "shop.oms"))).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Which project a recording of a flow belongs to.
|
|
2
|
+
//
|
|
3
|
+
// A recording is kept beside the project whose verify step reads it, and a
|
|
4
|
+
// flow says which context owns it - one level up from a project. The project
|
|
5
|
+
// is the one in that context whose service the flow runs through; when the
|
|
6
|
+
// context has one project, that one; otherwise the page has to ask.
|
|
7
|
+
|
|
8
|
+
import type { Flow } from "../catalog";
|
|
9
|
+
import type { SetupProject } from "./setup-info";
|
|
10
|
+
|
|
11
|
+
/** The projects a flow's recording could be kept under, the likeliest first. */
|
|
12
|
+
export function projectsForFlow(
|
|
13
|
+
projects: readonly SetupProject[],
|
|
14
|
+
flow: Pick<Flow, "owner" | "participants">,
|
|
15
|
+
): SetupProject[] {
|
|
16
|
+
const inContext = projects.filter(
|
|
17
|
+
(project) => (project.group ?? project.context) === flow.owner,
|
|
18
|
+
);
|
|
19
|
+
const services = new Set(
|
|
20
|
+
flow.participants
|
|
21
|
+
.filter((p) => p.kind === "service" && p.context === flow.owner)
|
|
22
|
+
.map((p) => p.id.split(".").slice(1).join(".")),
|
|
23
|
+
);
|
|
24
|
+
const runsThrough = (project: SetupProject) =>
|
|
25
|
+
services.has(project.component ?? project.service ?? "");
|
|
26
|
+
|
|
27
|
+
return [...inContext].sort(
|
|
28
|
+
(a, b) => Number(runsThrough(b)) - Number(runsThrough(a)) || a.id.localeCompare(b.id),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The one project a recording of the flow goes to, or null when it has to be asked. */
|
|
33
|
+
export function projectForFlow(
|
|
34
|
+
projects: readonly SetupProject[],
|
|
35
|
+
flow: Pick<Flow, "owner" | "participants">,
|
|
36
|
+
): SetupProject | null {
|
|
37
|
+
const candidates = projectsForFlow(projects, flow);
|
|
38
|
+
if (candidates.length === 1) return candidates[0]!;
|
|
39
|
+
if (candidates.length === 0) return null;
|
|
40
|
+
const services = new Set(
|
|
41
|
+
flow.participants
|
|
42
|
+
.filter((p) => p.kind === "service" && p.context === flow.owner)
|
|
43
|
+
.map((p) => p.id.split(".").slice(1).join(".")),
|
|
44
|
+
);
|
|
45
|
+
const through = candidates.filter((project) =>
|
|
46
|
+
services.has(project.component ?? project.service ?? ""),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return through.length === 1 ? through[0]! : null;
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The problems of the live catalog, for a component.
|
|
2
|
+
//
|
|
3
|
+
// Five places show the same list - the page, the sidebar badge, the overview,
|
|
4
|
+
// the landing's demo, the rules table - and each used to compute it once on
|
|
5
|
+
// mount. Now the list depends on the rules in force, which the Settings page
|
|
6
|
+
// changes under a running app, so the computation subscribes to them and the
|
|
7
|
+
// five places re-render together when a switch is flipped.
|
|
8
|
+
|
|
9
|
+
import { useMemo } from "react";
|
|
10
|
+
import { catalog, index } from "../data";
|
|
11
|
+
import { evaluateProblems } from "./all-problems";
|
|
12
|
+
import type { Problem } from "./derive";
|
|
13
|
+
import { useProblemRules } from "./problem-rules";
|
|
14
|
+
import type { RuleEvaluation } from "./problem-rules";
|
|
15
|
+
|
|
16
|
+
export function useProblemEvaluation(): RuleEvaluation {
|
|
17
|
+
const rules = useProblemRules();
|
|
18
|
+
return useMemo(() => evaluateProblems(catalog, index, rules), [rules]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useProblems(): Problem[] {
|
|
22
|
+
return useProblemEvaluation().problems;
|
|
23
|
+
}
|
package/src/lib/warnings.test.ts
CHANGED
|
@@ -31,6 +31,15 @@ describe("warning diagnostics", () => {
|
|
|
31
31
|
]);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("keeps one rule across the wordings the plugins emit for it", () => {
|
|
35
|
+
const rules = [
|
|
36
|
+
"calls ledger.v1 and the manifest names no peer for it; add it under `peers` to say which service answers, until then the calls are unresolved",
|
|
37
|
+
"src/app.rs: port `clock: Clock` is neither a domain port, a use case nor a client; its calls are left out of the flow",
|
|
38
|
+
"bus/router.go:12:3: Watermill handler orders is registered on topic `cfg.Topic`, which this reader cannot resolve to a literal, a constant, a config default or a caller's argument; the handler is kept with its topic unresolved",
|
|
39
|
+
].map((message) => warningDiagnostic({ plugin: "x", message }).rule);
|
|
40
|
+
expect(rules).toEqual(["catalog.unmapped-proto-peer", "flow.unknown-port", "watermill.unresolved-topic"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
34
43
|
it("marks read and parse failures as errors and expected absence as info", () => {
|
|
35
44
|
expect(warningDiagnostic({ plugin: "sql", message: "schema.sql could not be parsed" }).severity).toBe("error");
|
|
36
45
|
expect(warningDiagnostic({ plugin: "river", message: "root: no River jobs were found" }).severity).toBe("info");
|
package/src/lib/warnings.ts
CHANGED
|
@@ -68,7 +68,7 @@ const RULES: RuleDefinition[] = [
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
id: "catalog.unmapped-proto-peer",
|
|
71
|
-
matches: /\bmanifest names no peer for that package\b/i,
|
|
71
|
+
matches: /\bmanifest names no peer for (?:that package|it)\b/i,
|
|
72
72
|
severity: "warning",
|
|
73
73
|
action: "Map the protobuf package under peers, or declare it under externals.",
|
|
74
74
|
},
|
|
@@ -80,7 +80,7 @@ const RULES: RuleDefinition[] = [
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
id: "flow.unknown-port",
|
|
83
|
-
matches: /\bis neither a domain port nor a use case\b/i,
|
|
83
|
+
matches: /\bis neither a domain port(?:,| nor) a use case\b/i,
|
|
84
84
|
severity: "warning",
|
|
85
85
|
action: "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow.",
|
|
86
86
|
},
|
|
@@ -102,12 +102,6 @@ const RULES: RuleDefinition[] = [
|
|
|
102
102
|
severity: "warning",
|
|
103
103
|
action: "Choose an existing concrete model for the application's aggregates option.",
|
|
104
104
|
},
|
|
105
|
-
{
|
|
106
|
-
id: "django.ambiguous-aggregate-root",
|
|
107
|
-
matches: /\bmodels to choose from: name the root in the aggregates option\b/i,
|
|
108
|
-
severity: "warning",
|
|
109
|
-
action: "Choose the aggregate root explicitly in the django-domain aggregates option.",
|
|
110
|
-
},
|
|
111
105
|
{
|
|
112
106
|
id: "django.unknown-http-verb",
|
|
113
107
|
matches: /\bmounted as an HTTP view, but no HTTP verb is declared\b/i,
|
|
@@ -122,7 +116,7 @@ const RULES: RuleDefinition[] = [
|
|
|
122
116
|
},
|
|
123
117
|
{
|
|
124
118
|
id: "watermill.unresolved-topic",
|
|
125
|
-
matches: /\bWatermill
|
|
119
|
+
matches: /\bWatermill .+\btopic (?:generator could not be resolved|unresolved)\b/i,
|
|
126
120
|
severity: "warning",
|
|
127
121
|
action: "Use a literal, constant, or configuration default for the Watermill topic.",
|
|
128
122
|
},
|
|
@@ -152,10 +146,16 @@ const RULES: RuleDefinition[] = [
|
|
|
152
146
|
},
|
|
153
147
|
{
|
|
154
148
|
id: "source.offline-cache",
|
|
155
|
-
matches: /\bnot fetched \(offline\)
|
|
149
|
+
matches: /\bnot (?:fetched|read) \(offline\)/i,
|
|
156
150
|
severity: "info",
|
|
157
151
|
action: "Regenerate with network access when the vendored copy must be refreshed.",
|
|
158
152
|
},
|
|
153
|
+
{
|
|
154
|
+
id: "source.unreachable",
|
|
155
|
+
matches: /\bnot (?:fetched|read) \((?!offline\))/i,
|
|
156
|
+
severity: "warning",
|
|
157
|
+
action: "The far end could not be reached and the committed copy was used; check the server and the credential, then regenerate.",
|
|
158
|
+
},
|
|
159
159
|
{
|
|
160
160
|
id: "source.unpinned",
|
|
161
161
|
matches: /\bnot pinned\b/i,
|
package/src/likec4/ids.test.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
CONTAINERS_VIEW,
|
|
10
10
|
LANDSCAPE_VIEW,
|
|
11
11
|
allViewIds,
|
|
12
|
+
deployedServices,
|
|
13
|
+
environmentsOf,
|
|
12
14
|
contextViewId,
|
|
13
15
|
eventFqn,
|
|
14
16
|
flowCrossViewId,
|
|
@@ -100,14 +102,16 @@ describe("view ids", () => {
|
|
|
100
102
|
expect(new Set(ids).size).toBe(ids.length);
|
|
101
103
|
});
|
|
102
104
|
|
|
103
|
-
it("covers the landscape, the containers, every context, both views of every service
|
|
105
|
+
it("covers the landscape, the containers, every context, both views of every service, both of every flow, every environment and every deployed service", () => {
|
|
104
106
|
const ids = allViewIds(catalog);
|
|
105
107
|
const services = catalog.contexts.flatMap((c) => c.services);
|
|
106
108
|
expect(ids).toHaveLength(
|
|
107
109
|
2 +
|
|
108
110
|
catalog.contexts.length +
|
|
109
111
|
services.length * 2 +
|
|
110
|
-
catalog.flows.length * 2
|
|
112
|
+
catalog.flows.length * 2 +
|
|
113
|
+
environmentsOf(catalog).length +
|
|
114
|
+
deployedServices(catalog).length,
|
|
111
115
|
);
|
|
112
116
|
// The three C4 levels: the estate, its containers, a context, and a
|
|
113
117
|
// service opened up.
|
package/src/likec4/ids.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
Participant,
|
|
10
10
|
Service,
|
|
11
11
|
} from "../catalog";
|
|
12
|
+
import { allDeployments, deploys, environmentOf } from "../catalog";
|
|
12
13
|
import reserved from "./reserved.json";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -97,6 +98,42 @@ export const flowViewId = (flow: Flow | string): string =>
|
|
|
97
98
|
export const flowCrossViewId = (flow: Flow | string): string =>
|
|
98
99
|
`${flowViewId(flow)}_cross`;
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Where the services run, one environment at a time: the clusters and
|
|
103
|
+
* namespaces of the environment as frames, an instance of every service the
|
|
104
|
+
* snapshot places there, and between the instances what the model declares
|
|
105
|
+
* between their services (portolan.0012).
|
|
106
|
+
*/
|
|
107
|
+
export const deploymentViewId = (environment: string): string =>
|
|
108
|
+
`deploy_${safeId(environment)}`;
|
|
109
|
+
|
|
110
|
+
/** One service, every place it runs: its instances, with the frames around each. */
|
|
111
|
+
export const serviceDeployViewId = (service: Service | string): string =>
|
|
112
|
+
`deploy_svc_${safeId(typeof service === "string" ? service : service.id)}`;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The environments the snapshot places services in, sorted. A deployment of
|
|
116
|
+
* a repository no service claims places nothing and names no environment
|
|
117
|
+
* here: an environment with nothing of the estate in it is not a picture.
|
|
118
|
+
*/
|
|
119
|
+
export function environmentsOf(catalog: Catalog): string[] {
|
|
120
|
+
const services = catalog.contexts.flatMap((context) => context.services);
|
|
121
|
+
const found = new Set<string>();
|
|
122
|
+
for (const deployment of allDeployments(catalog)) {
|
|
123
|
+
if (!services.some((service) => deploys(deployment, service))) continue;
|
|
124
|
+
found.add(environmentOf(deployment));
|
|
125
|
+
}
|
|
126
|
+
return [...found].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The services the snapshot places somewhere, in catalog order. */
|
|
130
|
+
export function deployedServices(catalog: Catalog): Service[] {
|
|
131
|
+
const deployments = allDeployments(catalog);
|
|
132
|
+
return catalog.contexts
|
|
133
|
+
.flatMap((context) => context.services)
|
|
134
|
+
.filter((service) => deployments.some((d) => deploys(d, service)));
|
|
135
|
+
}
|
|
136
|
+
|
|
100
137
|
/** Every view id the generator is expected to emit, in a stable order. */
|
|
101
138
|
export function allViewIds(catalog: Catalog): string[] {
|
|
102
139
|
const out: string[] = [LANDSCAPE_VIEW, CONTAINERS_VIEW];
|
|
@@ -111,5 +148,11 @@ export function allViewIds(catalog: Catalog): string[] {
|
|
|
111
148
|
out.push(flowViewId(flow));
|
|
112
149
|
out.push(flowCrossViewId(flow));
|
|
113
150
|
}
|
|
151
|
+
for (const environment of environmentsOf(catalog)) {
|
|
152
|
+
out.push(deploymentViewId(environment));
|
|
153
|
+
}
|
|
154
|
+
for (const service of deployedServices(catalog)) {
|
|
155
|
+
out.push(serviceDeployViewId(service));
|
|
156
|
+
}
|
|
114
157
|
return out;
|
|
115
158
|
}
|
package/src/main.tsx
CHANGED
|
@@ -3,6 +3,29 @@ import { createRoot } from "react-dom/client";
|
|
|
3
3
|
import "./index.css";
|
|
4
4
|
import { App } from "./app/App";
|
|
5
5
|
|
|
6
|
+
// A deploy replaces every hashed chunk at once, but a static host may keep
|
|
7
|
+
// serving the old index.html from cache for a while (GitLab Pages caches every
|
|
8
|
+
// file for ten minutes and lets a project set no headers of its own). A tab
|
|
9
|
+
// that opened on the old page then asks for chunks that no longer exist, the
|
|
10
|
+
// lazy import fails, and the reader sees an empty canvas. Vite reports that
|
|
11
|
+
// failure here before it throws; one reload revalidates the document and
|
|
12
|
+
// picks up the new names. The guard is keyed by the chunk that failed, so a
|
|
13
|
+
// chunk that is truly missing cannot reload the page forever.
|
|
14
|
+
const RELOADED = "portolan:reloaded-for";
|
|
15
|
+
window.addEventListener("vite:preloadError", (event) => {
|
|
16
|
+
const failed = String((event as Event & { payload?: unknown }).payload ?? "");
|
|
17
|
+
let already = "";
|
|
18
|
+
try {
|
|
19
|
+
already = sessionStorage.getItem(RELOADED) ?? "";
|
|
20
|
+
sessionStorage.setItem(RELOADED, failed);
|
|
21
|
+
} catch {
|
|
22
|
+
// Storage may be unavailable; a single reload is still worth trying.
|
|
23
|
+
}
|
|
24
|
+
if (already === failed) return;
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
window.location.reload();
|
|
27
|
+
});
|
|
28
|
+
|
|
6
29
|
const container = document.getElementById("root");
|
|
7
30
|
if (!container) throw new Error("#root is missing from index.html");
|
|
8
31
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Deployment } from "./catalog";
|
|
3
|
+
import { mergeCatalogs } from "./merge";
|
|
4
|
+
import type { CatalogSource } from "./merge";
|
|
5
|
+
|
|
6
|
+
function source(path: string, deployments: Deployment[]): CatalogSource {
|
|
7
|
+
return {
|
|
8
|
+
path,
|
|
9
|
+
catalog: {
|
|
10
|
+
generatedAt: "",
|
|
11
|
+
commit: "",
|
|
12
|
+
contexts: [],
|
|
13
|
+
defs: {},
|
|
14
|
+
flows: [],
|
|
15
|
+
adrs: [],
|
|
16
|
+
deployments,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const manifest = (over: Partial<Deployment> = {}): Deployment => ({
|
|
22
|
+
id: "argocd/shop-cart-prod",
|
|
23
|
+
name: "shop-cart-prod",
|
|
24
|
+
project: "shop",
|
|
25
|
+
environment: "prod",
|
|
26
|
+
cluster: "in-cluster",
|
|
27
|
+
namespace: "shop",
|
|
28
|
+
repo: "github.com/acme/gitops",
|
|
29
|
+
path: "envs/prod/shop/cart",
|
|
30
|
+
targetRevision: "main",
|
|
31
|
+
revision: "",
|
|
32
|
+
tool: "kustomize",
|
|
33
|
+
url: "",
|
|
34
|
+
service: "shop.cart",
|
|
35
|
+
images: ["ghcr.io/acme/cart:2.1.0"],
|
|
36
|
+
basis: "manifest",
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const api = (over: Partial<Deployment> = {}): Deployment => ({
|
|
41
|
+
id: "argocd/shop-cart-prod",
|
|
42
|
+
name: "shop-cart-prod",
|
|
43
|
+
project: "shop",
|
|
44
|
+
environment: "prod",
|
|
45
|
+
cluster: "in-cluster",
|
|
46
|
+
namespace: "shop",
|
|
47
|
+
repo: "github.com/acme/gitops",
|
|
48
|
+
path: "envs/prod/shop/cart",
|
|
49
|
+
targetRevision: "main",
|
|
50
|
+
revision: "a".repeat(40),
|
|
51
|
+
tool: "kustomize",
|
|
52
|
+
url: "https://argocd.example.com/applications/argocd/shop-cart-prod",
|
|
53
|
+
images: ["ghcr.io/acme/cart:2.1.0", "redis:7"],
|
|
54
|
+
basis: "api",
|
|
55
|
+
...over,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("the merge lays the tree under the deployer", () => {
|
|
59
|
+
it("folds a manifest row and an api row into one that the deployer shapes and the tree fills, with no drift when they agree", () => {
|
|
60
|
+
const { catalog, conflicts } = mergeCatalogs([
|
|
61
|
+
source("gitops/portolan/argocd.json", [manifest()]),
|
|
62
|
+
source("argocd/argocd.apps.json", [api({ service: undefined })]),
|
|
63
|
+
]);
|
|
64
|
+
expect(conflicts).toEqual([]);
|
|
65
|
+
expect(catalog.deployments).toHaveLength(1);
|
|
66
|
+
const [row] = catalog.deployments!;
|
|
67
|
+
expect(row).toMatchObject({
|
|
68
|
+
basis: "both",
|
|
69
|
+
revision: "a".repeat(40),
|
|
70
|
+
url: "https://argocd.example.com/applications/argocd/shop-cart-prod",
|
|
71
|
+
// The labels the tree carries place the row when the deployer's did not.
|
|
72
|
+
service: "shop.cart",
|
|
73
|
+
// What runs, not what is pinned: the base's redis is running too.
|
|
74
|
+
images: ["ghcr.io/acme/cart:2.1.0", "redis:7"],
|
|
75
|
+
});
|
|
76
|
+
expect(row?.drift).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps the tree's word as drift where the deployer says otherwise, whichever arrives first", () => {
|
|
80
|
+
const tree = manifest({ targetRevision: "release-2.1", images: ["ghcr.io/acme/cart:2.1.0"], namespace: "shop" });
|
|
81
|
+
const cluster = api({ targetRevision: "main", images: ["ghcr.io/acme/cart:2.0.9"], namespace: "shop-old" });
|
|
82
|
+
for (const order of [[tree, cluster], [cluster, tree]]) {
|
|
83
|
+
const { catalog, conflicts } = mergeCatalogs([
|
|
84
|
+
source("first.json", [order[0]!]),
|
|
85
|
+
source("second.json", [order[1]!]),
|
|
86
|
+
]);
|
|
87
|
+
expect(conflicts).toEqual([]);
|
|
88
|
+
const [row] = catalog.deployments!;
|
|
89
|
+
expect(row?.basis).toBe("both");
|
|
90
|
+
expect(row?.targetRevision).toBe("main");
|
|
91
|
+
expect(row?.namespace).toBe("shop-old");
|
|
92
|
+
expect(row?.drift).toEqual({
|
|
93
|
+
namespace: "shop",
|
|
94
|
+
targetRevision: "release-2.1",
|
|
95
|
+
images: ["ghcr.io/acme/cart:2.1.0"],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not call a pinned image drift when nothing runs yet, and shows the pinned ones instead", () => {
|
|
101
|
+
const { catalog } = mergeCatalogs([
|
|
102
|
+
source("tree.json", [manifest()]),
|
|
103
|
+
source("api.json", [api({ images: undefined, revision: "" })]),
|
|
104
|
+
]);
|
|
105
|
+
const [row] = catalog.deployments!;
|
|
106
|
+
expect(row?.images).toEqual(["ghcr.io/acme/cart:2.1.0"]);
|
|
107
|
+
expect(row?.drift).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves a row only one source spoke for as it was, and reports two of one basis as a collision", () => {
|
|
111
|
+
// Sources are read in path order, so the names say who comes first.
|
|
112
|
+
const { catalog, conflicts } = mergeCatalogs([
|
|
113
|
+
source("a-tree.json", [manifest()]),
|
|
114
|
+
source("b-tree.json", [manifest({ path: "elsewhere" })]),
|
|
115
|
+
source("c-api.json", [api()]),
|
|
116
|
+
source("d-api.json", [api({ revision: "b".repeat(40) })]),
|
|
117
|
+
]);
|
|
118
|
+
expect(catalog.deployments).toHaveLength(1);
|
|
119
|
+
expect(catalog.deployments?.[0]?.revision).toBe("a".repeat(40));
|
|
120
|
+
expect(conflicts.map((c) => c.path)).toEqual(["b-tree.json", "d-api.json"]);
|
|
121
|
+
expect(conflicts[0]?.message).toContain("declared here and in a-tree.json");
|
|
122
|
+
expect(conflicts[1]?.message).toContain("listed here and in c-api.json");
|
|
123
|
+
|
|
124
|
+
const alone = mergeCatalogs([source("tree.json", [manifest()])]);
|
|
125
|
+
expect(alone.catalog.deployments?.[0]).toEqual(manifest());
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/merge.test.ts
CHANGED
|
@@ -858,6 +858,88 @@ describe("a second source that has seen the flow run", () => {
|
|
|
858
858
|
expect(first?.type === "step" ? first.status : "").toBe("declared");
|
|
859
859
|
});
|
|
860
860
|
|
|
861
|
+
it("takes a hop a recording showed that the code does not declare, where the recording put it", () => {
|
|
862
|
+
const seen = {
|
|
863
|
+
type: "step" as const,
|
|
864
|
+
id: "seen1",
|
|
865
|
+
from: "auth.auth",
|
|
866
|
+
to: "profile",
|
|
867
|
+
kind: "rpc" as const,
|
|
868
|
+
label: "GET /v1/profiles/42",
|
|
869
|
+
status: "unresolved" as const,
|
|
870
|
+
seen: { traces: 2 },
|
|
871
|
+
};
|
|
872
|
+
const lane = { id: "profile", kind: "unknown" as const, context: null, label: "profile" };
|
|
873
|
+
const example = { id: "telemetry/traces.jsonl#t1", recording: "telemetry/traces.jsonl", traceId: "t1", durationMs: 2.5, steps: [{ step: "s1", durationMs: 2.5 }] };
|
|
874
|
+
const merged = mergeCatalogs([
|
|
875
|
+
source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
|
|
876
|
+
source("b.json", {
|
|
877
|
+
flows: [{
|
|
878
|
+
...flow([{ ...step("s1", "verified"), seen: { traces: 2 } }, seen, step("s2", "declared")]),
|
|
879
|
+
participants: [...flow([]).participants, lane],
|
|
880
|
+
examples: [example],
|
|
881
|
+
}],
|
|
882
|
+
}),
|
|
883
|
+
]);
|
|
884
|
+
|
|
885
|
+
expect(merged.conflicts).toEqual([]);
|
|
886
|
+
const out = merged.catalog.flows[0]!;
|
|
887
|
+
expect(out.steps.map((s) => (s.type === "step" ? `${s.id}:${s.status}` : s.type))).toEqual(["s1:verified", "seen1:unresolved", "s2:declared"]);
|
|
888
|
+
expect(out.steps[0]?.type === "step" ? out.steps[0].seen : undefined).toEqual({ traces: 2 });
|
|
889
|
+
expect(out.participants.map((p) => p.id)).toEqual(["client", "auth.auth", "profile"]);
|
|
890
|
+
expect(out.examples).toEqual([example]);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("takes a frame where the recordings parted, and not a frame the code did not write", () => {
|
|
894
|
+
const seenStep = (id: string, label: string) => ({ ...step(id, "verified" as const), label, seen: { traces: 1 } });
|
|
895
|
+
const frame = {
|
|
896
|
+
type: "alt" as const,
|
|
897
|
+
id: "seen-alt1",
|
|
898
|
+
branches: [
|
|
899
|
+
{ title: "getUser", steps: [seenStep("seen1", "getUser")], seen: { traces: 1 } },
|
|
900
|
+
{ title: "otherwise", steps: [], seen: { traces: 1 } },
|
|
901
|
+
],
|
|
902
|
+
};
|
|
903
|
+
const merged = mergeCatalogs([
|
|
904
|
+
source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
|
|
905
|
+
source("b.json", { flows: [flow([step("s1", "verified"), frame, step("s2", "declared")])] }),
|
|
906
|
+
]);
|
|
907
|
+
expect(merged.conflicts).toEqual([]);
|
|
908
|
+
expect(merged.catalog.flows[0]?.steps.map((s) => s.id)).toEqual(["s1", "seen-alt1", "s2"]);
|
|
909
|
+
|
|
910
|
+
const unseen = { ...frame, branches: [{ title: "x", steps: [step("x1", "verified")] }] };
|
|
911
|
+
const refused = mergeCatalogs([
|
|
912
|
+
source("a.json", { flows: [flow([step("s1", "declared")])] }),
|
|
913
|
+
source("b.json", { flows: [flow([step("s1", "verified"), unseen])] }),
|
|
914
|
+
]);
|
|
915
|
+
expect(refused.conflicts).toHaveLength(1);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("is still a conflict when a lane the code declared is missing or a declared step is", () => {
|
|
919
|
+
const fewerLanes = mergeCatalogs([
|
|
920
|
+
source("a.json", { flows: [flow([step("s1", "declared")])] }),
|
|
921
|
+
source("b.json", { flows: [{ ...flow([step("s1", "verified")]), participants: [{ id: "client", kind: "actor" as const, context: null }] }] }),
|
|
922
|
+
]);
|
|
923
|
+
expect(fewerLanes.conflicts).toHaveLength(1);
|
|
924
|
+
|
|
925
|
+
const fewerSteps = mergeCatalogs([
|
|
926
|
+
source("a.json", { flows: [flow([step("s1", "declared"), step("s2", "declared")])] }),
|
|
927
|
+
source("b.json", { flows: [flow([step("s1", "verified")])] }),
|
|
928
|
+
]);
|
|
929
|
+
expect(fewerSteps.conflicts).toHaveLength(1);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("keeps an example it already has when a second recording says it again", () => {
|
|
933
|
+
const example = (id: string) => ({ id, recording: "t.jsonl", traceId: id, durationMs: 1, steps: [] });
|
|
934
|
+
const merged = mergeCatalogs([
|
|
935
|
+
source("a.json", { flows: [{ ...flow([step("s1", "declared")]), examples: [example("a")] }] }),
|
|
936
|
+
source("b.json", { flows: [{ ...flow([step("s1", "verified")]), examples: [example("a"), example("b")] }] }),
|
|
937
|
+
]);
|
|
938
|
+
|
|
939
|
+
expect(merged.conflicts).toEqual([]);
|
|
940
|
+
expect(merged.catalog.flows[0]?.examples?.map((e) => e.id)).toEqual(["a", "b"]);
|
|
941
|
+
});
|
|
942
|
+
|
|
861
943
|
it("lets verified win for a consumer and a call declared twice", () => {
|
|
862
944
|
const withEdges = (status: "declared" | "verified") => {
|
|
863
945
|
const ctx = context("shop", ["shop.oms"]);
|