@shortlink-org/portolan 0.2.3 → 0.3.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 +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +114 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -4
- package/plugins/extract-python-kafka/extract_test.py +18 -2
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +79 -21
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +70 -6
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +52 -2
- package/src/catalog.test.ts +13 -2
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- 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/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/ProblemRow.tsx +4 -0
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +336 -6
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +104 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- 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 +20 -2
- package/src/lib/derive.ts +1 -0
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- 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/map/ContextMapGraph.tsx +76 -32
- package/src/merge.test.ts +23 -0
- package/src/merge.ts +33 -10
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +200 -44
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- 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 +61 -1
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
package/src/catalog-model.ts
CHANGED
|
@@ -297,7 +297,11 @@ export interface RpcMethod {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
export interface HttpRoute {
|
|
300
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* Upper case: `POST`. Empty when a framework extractor proved the mount but
|
|
302
|
+
* no declaration proved the verb; such a route is never matched against an
|
|
303
|
+
* outbound call, and renderers show the path alone.
|
|
304
|
+
*/
|
|
301
305
|
method: string;
|
|
302
306
|
/** As templated in the document: `/v1/users/{id}`. */
|
|
303
307
|
path: string;
|
|
@@ -361,7 +365,32 @@ export interface EdgeVia {
|
|
|
361
365
|
flow: string; // Flow.slug
|
|
362
366
|
step: string; // Step.id
|
|
363
367
|
}
|
|
368
|
+
/** Source facts remain separate from the merge's choice of provider. */
|
|
369
|
+
export interface HTTPDestination {
|
|
370
|
+
callSite: string;
|
|
371
|
+
endpointExpression: string;
|
|
372
|
+
method: string;
|
|
373
|
+
localPath?: string;
|
|
374
|
+
baseURL?: HTTPBaseURL;
|
|
375
|
+
serviceDiscoveryAlias?: string;
|
|
376
|
+
fullPath?: string;
|
|
377
|
+
join?: { expression: string; source: string };
|
|
378
|
+
/** Runtime URL modifiers after the proven join; not evaluated statically. */
|
|
379
|
+
transforms?: { expression: string; source: string }[];
|
|
380
|
+
resolution?: { basis: "full-path" | "exact-route" | "unique-suffix"; provider: string; route: string };
|
|
381
|
+
}
|
|
382
|
+
export interface HTTPBaseURL {
|
|
383
|
+
expression: string;
|
|
384
|
+
configField?: string;
|
|
385
|
+
environmentVariable?: string;
|
|
386
|
+
value?: string;
|
|
387
|
+
kind: "literal" | "config-default" | "symbolic";
|
|
388
|
+
source: string;
|
|
389
|
+
optionSource?: string;
|
|
390
|
+
}
|
|
364
391
|
export interface RpcCall {
|
|
392
|
+
evidence?: RelationEvidence[];
|
|
393
|
+
destination?: HTTPDestination;
|
|
365
394
|
id: string; // "<proto.package.Service>/<Method>"
|
|
366
395
|
peer: string; // service id if resolved, else raw name
|
|
367
396
|
status: Status;
|
|
@@ -447,7 +476,9 @@ export interface Aggregate {
|
|
|
447
476
|
slug: string;
|
|
448
477
|
name: string;
|
|
449
478
|
readme: string;
|
|
450
|
-
/**
|
|
479
|
+
/** A source grouping has no confirmed aggregate boundary. Omitted for aggregates. */
|
|
480
|
+
kind?: "model-group";
|
|
481
|
+
/** Name of the root entity; empty only for a model-group. */
|
|
451
482
|
root: string;
|
|
452
483
|
entities: Entity[];
|
|
453
484
|
valueObjects: ValueObject[];
|
|
@@ -627,6 +658,10 @@ export interface ChannelMessage {
|
|
|
627
658
|
title?: string;
|
|
628
659
|
doc?: string;
|
|
629
660
|
direction: ChannelDirection;
|
|
661
|
+
/** Normalized payload serialization, such as `msgpack`. */
|
|
662
|
+
encoding?: string;
|
|
663
|
+
/** Exact media type declared by the source contract. */
|
|
664
|
+
contentType?: string;
|
|
630
665
|
}
|
|
631
666
|
export interface EventConsumer {
|
|
632
667
|
service: string;
|
|
@@ -730,7 +765,7 @@ export interface RedisKeyspace {
|
|
|
730
765
|
value?: string;
|
|
731
766
|
source?: string;
|
|
732
767
|
/** Aggregate or block whose value this key family holds, when provable. */
|
|
733
|
-
persists?: { aggregate?: string; block?: string };
|
|
768
|
+
persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
|
|
734
769
|
/** Individual client calls, before they are folded into `operations`. */
|
|
735
770
|
accesses?: RedisAccess[];
|
|
736
771
|
}
|
|
@@ -763,14 +798,32 @@ export const TABLE_ROLES: readonly TableRole[] = [
|
|
|
763
798
|
] as const;
|
|
764
799
|
|
|
765
800
|
export interface Table {
|
|
801
|
+
evidence?: RelationEvidence[];
|
|
766
802
|
id: string; // "<store id>.<table>"
|
|
767
803
|
name: string;
|
|
768
804
|
doc?: string;
|
|
769
805
|
columns: Column[];
|
|
770
806
|
indexes?: TableIndex[];
|
|
771
807
|
/** The domain object this table holds: an aggregate id, and optionally a block id. */
|
|
772
|
-
persists?: { aggregate?: string; block?: string };
|
|
808
|
+
persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
|
|
773
809
|
role?: TableRole;
|
|
810
|
+
/** Source-backed repository methods that read or write this table. */
|
|
811
|
+
accesses?: TableAccess[];
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export type TableOperation = "read" | "write" | "delete";
|
|
815
|
+
|
|
816
|
+
export const TABLE_OPERATIONS: readonly TableOperation[] = [
|
|
817
|
+
"read",
|
|
818
|
+
"write",
|
|
819
|
+
"delete",
|
|
820
|
+
] as const;
|
|
821
|
+
|
|
822
|
+
export interface TableAccess {
|
|
823
|
+
operation: TableOperation;
|
|
824
|
+
/** Enclosing adapter method, for example `Postgres.Save`. */
|
|
825
|
+
method?: string;
|
|
826
|
+
source?: string;
|
|
774
827
|
}
|
|
775
828
|
|
|
776
829
|
export interface TableIndex {
|
|
@@ -832,7 +885,7 @@ export interface View {
|
|
|
832
885
|
/** The SELECT, as the migration declares it. Shown, never parsed. */
|
|
833
886
|
definition?: string;
|
|
834
887
|
/** The domain object this view presents, when it presents exactly one. */
|
|
835
|
-
persists?: { aggregate?: string; block?: string };
|
|
888
|
+
persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
|
|
836
889
|
/** Migration or model file, as a reader would open it. */
|
|
837
890
|
source?: string;
|
|
838
891
|
}
|
|
@@ -887,13 +940,24 @@ export interface Participant {
|
|
|
887
940
|
label?: string;
|
|
888
941
|
}
|
|
889
942
|
export type FlowNode = Step | Parallel | Alt | Loop;
|
|
943
|
+
/** Source facts used to derive a relationship; not a runtime trace. */
|
|
944
|
+
export interface RelationEvidence {
|
|
945
|
+
kind: "call-site" | "function" | "binding" | "contract" | "resolution" | "unresolved";
|
|
946
|
+
rule: string;
|
|
947
|
+
source?: string;
|
|
948
|
+
symbol?: string;
|
|
949
|
+
candidates?: string[];
|
|
950
|
+
}
|
|
951
|
+
|
|
890
952
|
export interface Step {
|
|
953
|
+
evidence?: RelationEvidence[];
|
|
954
|
+
destination?: HTTPDestination;
|
|
891
955
|
type: "step";
|
|
892
956
|
id: string;
|
|
893
957
|
from: string;
|
|
894
958
|
to: string; // participant ids; from === to is a self-message
|
|
895
959
|
kind: "rpc" | "event" | "call" | "response";
|
|
896
|
-
ref?: string; // Event.id
|
|
960
|
+
ref?: string; // Event.id, RpcCall.id or provided RPC method id; otherwise unresolved
|
|
897
961
|
label?: string;
|
|
898
962
|
status: Status;
|
|
899
963
|
note?: string;
|
|
@@ -45,6 +45,23 @@ describe("store validation", () => {
|
|
|
45
45
|
expect(() => validateCatalog(clone())).not.toThrow();
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("accepts source-backed table readers and writers", () => {
|
|
49
|
+
const good = clone();
|
|
50
|
+
omsStore(good).tables[0]!.accesses = [
|
|
51
|
+
{ operation: "read", method: "Postgres.ByID", source: "postgres.go:40" },
|
|
52
|
+
{ operation: "write", method: "Postgres.Save", source: "postgres.go:20" },
|
|
53
|
+
];
|
|
54
|
+
expect(() => validateCatalog(good)).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects an unknown table access operation", () => {
|
|
58
|
+
const bad = clone();
|
|
59
|
+
omsStore(bad).tables[0]!.accesses = [
|
|
60
|
+
{ operation: "merge" as "read", method: "Postgres.Save" },
|
|
61
|
+
];
|
|
62
|
+
expect(failureOf(bad).message).toContain("access operation");
|
|
63
|
+
});
|
|
64
|
+
|
|
48
65
|
it("rejects a foreign key into a table nobody declared", () => {
|
|
49
66
|
const bad = clone();
|
|
50
67
|
const table = omsStore(bad).tables.find((t) => t.name === "order_items");
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
REDIS_OPERATIONS,
|
|
17
17
|
STORE_KINDS,
|
|
18
18
|
STREAMING,
|
|
19
|
+
TABLE_OPERATIONS,
|
|
19
20
|
TABLE_ROLES,
|
|
20
21
|
aggregateBlocks,
|
|
21
22
|
allAggregates,
|
|
@@ -124,6 +125,18 @@ function validateChannels(service: Service): void {
|
|
|
124
125
|
`service ${service.id} / channel ${channel.address}`,
|
|
125
126
|
);
|
|
126
127
|
}
|
|
128
|
+
if (message.encoding !== undefined && (typeof message.encoding !== "string" || message.encoding === "")) {
|
|
129
|
+
fail(
|
|
130
|
+
`message "${message.name}" on channel "${channel.address}" has an empty encoding`,
|
|
131
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (message.contentType !== undefined && (typeof message.contentType !== "string" || message.contentType === "")) {
|
|
135
|
+
fail(
|
|
136
|
+
`message "${message.name}" on channel "${channel.address}" has an empty content type`,
|
|
137
|
+
`service ${service.id} / channel ${channel.address}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
127
140
|
const key = `${message.direction} ${message.name}`;
|
|
128
141
|
if (seen.has(key)) {
|
|
129
142
|
fail(
|
|
@@ -142,6 +155,15 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
142
155
|
|
|
143
156
|
const eventIds = new Set<string>();
|
|
144
157
|
const rpcIds = new Set<string>();
|
|
158
|
+
const providedRpcRefs = new Set(
|
|
159
|
+
allExternals(catalog).flatMap((external) =>
|
|
160
|
+
external.provides.flatMap((provided) =>
|
|
161
|
+
provided.methods.map(
|
|
162
|
+
(method) => `${external.id}|${provided.id}/${method.name}`,
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
145
167
|
const storeIds = new Set(allStores(catalog).map((store) => store.id));
|
|
146
168
|
|
|
147
169
|
assertUniqueSlugs(
|
|
@@ -232,6 +254,13 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
232
254
|
technologies.add(technology);
|
|
233
255
|
}
|
|
234
256
|
for (const call of service.consumes) rpcIds.add(call.id);
|
|
257
|
+
for (const provided of service.provides) {
|
|
258
|
+
for (const method of provided.methods) {
|
|
259
|
+
providedRpcRefs.add(
|
|
260
|
+
`${service.id}|${provided.id}/${method.name}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
235
264
|
for (const provided of [
|
|
236
265
|
...service.provides,
|
|
237
266
|
...(service.copies ?? []),
|
|
@@ -589,10 +618,14 @@ export function validateCatalog(catalog: Catalog): Catalog {
|
|
|
589
618
|
);
|
|
590
619
|
}
|
|
591
620
|
if (step.ref !== undefined && step.status !== "unresolved") {
|
|
592
|
-
const resolves =
|
|
621
|
+
const resolves =
|
|
622
|
+
eventIds.has(step.ref) ||
|
|
623
|
+
rpcIds.has(step.ref) ||
|
|
624
|
+
(step.kind === "rpc" &&
|
|
625
|
+
providedRpcRefs.has(`${step.to}|${step.ref}`));
|
|
593
626
|
if (!resolves) {
|
|
594
627
|
fail(
|
|
595
|
-
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event nor
|
|
628
|
+
`flow "${flow.slug}" step "${step.id}": ref "${step.ref}" resolves to neither an Event, an RpcCall nor a method provided by "${step.to}", and status is "${step.status}" rather than "unresolved"`,
|
|
596
629
|
`flow ${flow.id} / step ${step.id}`,
|
|
597
630
|
);
|
|
598
631
|
}
|
|
@@ -990,6 +1023,14 @@ function validateStores(catalog: Catalog): void {
|
|
|
990
1023
|
where,
|
|
991
1024
|
);
|
|
992
1025
|
}
|
|
1026
|
+
for (const access of table.accesses ?? []) {
|
|
1027
|
+
if (!TABLE_OPERATIONS.includes(access.operation)) {
|
|
1028
|
+
fail(
|
|
1029
|
+
`table "${table.id}" has access operation "${access.operation}"; expected one of ${TABLE_OPERATIONS.join(", ")}`,
|
|
1030
|
+
where,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
993
1034
|
|
|
994
1035
|
const own = columnsOfTable.get(table.id) ?? new Set<string>();
|
|
995
1036
|
if (own.size !== table.columns.length) {
|
|
@@ -1311,6 +1352,15 @@ function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
|
|
|
1311
1352
|
|
|
1312
1353
|
validateEnums(aggregate);
|
|
1313
1354
|
|
|
1355
|
+
if (aggregate.kind !== undefined && aggregate.kind !== "model-group") {
|
|
1356
|
+
fail(`aggregate "${aggregate.id}" has an unknown kind`, `aggregate ${aggregate.id}`);
|
|
1357
|
+
}
|
|
1358
|
+
if (aggregate.kind === "model-group") {
|
|
1359
|
+
if (aggregate.root !== "" || aggregate.lifecycle) {
|
|
1360
|
+
fail(`model group "${aggregate.id}" cannot declare an aggregate root or lifecycle`, `aggregate ${aggregate.id}`);
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1314
1364
|
if (!aggregate.root) {
|
|
1315
1365
|
fail(
|
|
1316
1366
|
`aggregate "${aggregate.id}" names no root entity`,
|
package/src/catalog.test.ts
CHANGED
|
@@ -105,7 +105,7 @@ describe("validateCatalog", () => {
|
|
|
105
105
|
if (!step) throw new Error("fixture has no steps");
|
|
106
106
|
step.ref = "shop.oms.order.NoSuchEvent";
|
|
107
107
|
expect(() => validateCatalog(bad)).toThrowError(
|
|
108
|
-
/resolves to neither an Event nor
|
|
108
|
+
/resolves to neither an Event, an RpcCall nor a method/,
|
|
109
109
|
);
|
|
110
110
|
step.status = "unresolved";
|
|
111
111
|
expect(() => validateCatalog(bad)).not.toThrow();
|
|
@@ -1077,7 +1077,10 @@ describe("validateCatalog: channels", () => {
|
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
1079
|
it("accepts a service that declares what it says on the bus", () => {
|
|
1080
|
-
|
|
1080
|
+
const good = speaking();
|
|
1081
|
+
good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.encoding = "msgpack";
|
|
1082
|
+
good.contexts[0]!.services[0]!.channels![0]!.messages[0]!.contentType = "application/msgpack";
|
|
1083
|
+
expect(() => validateCatalog(good)).not.toThrow();
|
|
1081
1084
|
});
|
|
1082
1085
|
|
|
1083
1086
|
// A service with no document is the normal case, and it is not a service
|
|
@@ -1130,6 +1133,14 @@ describe("validateCatalog: channels", () => {
|
|
|
1130
1133
|
|
|
1131
1134
|
expect(() => validateCatalog(bad)).toThrow(/neither send nor receive/);
|
|
1132
1135
|
});
|
|
1136
|
+
|
|
1137
|
+
it("rejects an explicitly empty message encoding", () => {
|
|
1138
|
+
const bad = speaking();
|
|
1139
|
+
const message = bad.contexts[0]!.services[0]!.channels![0]!.messages[0]!;
|
|
1140
|
+
message.encoding = "";
|
|
1141
|
+
|
|
1142
|
+
expect(() => validateCatalog(bad)).toThrow(/empty encoding/);
|
|
1143
|
+
});
|
|
1133
1144
|
});
|
|
1134
1145
|
|
|
1135
1146
|
// A system outside the estate with a contract sits at the root beside the
|
package/src/chat/Starter.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { KindIcon } from "../components/kind";
|
|
8
8
|
import type { Kind } from "../lib/kinds";
|
|
9
|
-
import {
|
|
9
|
+
import { activeCatalogDocs, catalog } from "../data";
|
|
10
10
|
import type { ChatPageContext } from "./page-context";
|
|
11
11
|
import { contextQuestions } from "./page-context";
|
|
12
12
|
|
|
@@ -36,8 +36,6 @@ function examples(): Example[] {
|
|
|
36
36
|
return out;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const base = import.meta.env.BASE_URL;
|
|
40
|
-
|
|
41
39
|
export function Starter({
|
|
42
40
|
onAsk,
|
|
43
41
|
page,
|
|
@@ -51,10 +49,6 @@ export function Starter({
|
|
|
51
49
|
question,
|
|
52
50
|
}))
|
|
53
51
|
: examples();
|
|
54
|
-
const profileSuffix =
|
|
55
|
-
activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
|
|
56
|
-
const docsRoot = `${base}docs/${profileSuffix}`;
|
|
57
|
-
const indexHref = profileSuffix ? `${docsRoot}llms.txt` : `${base}llms.txt`;
|
|
58
52
|
|
|
59
53
|
return (
|
|
60
54
|
<div className="pt-1">
|
|
@@ -79,10 +73,10 @@ export function Starter({
|
|
|
79
73
|
<div className="mono truncate text-muted" title={page.id}>{page.id}</div>
|
|
80
74
|
</div>
|
|
81
75
|
</div>
|
|
82
|
-
<div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
|
|
76
|
+
{activeCatalogDocs ? <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
|
|
83
77
|
<span className="text-faint">model context</span>
|
|
84
78
|
<a
|
|
85
|
-
href={
|
|
79
|
+
href={activeCatalogDocs.index}
|
|
86
80
|
target="_blank"
|
|
87
81
|
rel="noreferrer"
|
|
88
82
|
className="rounded-control text-accent hover:underline"
|
|
@@ -91,7 +85,7 @@ export function Starter({
|
|
|
91
85
|
</a>
|
|
92
86
|
{page.docPath ? (
|
|
93
87
|
<a
|
|
94
|
-
href={`${
|
|
88
|
+
href={`${activeCatalogDocs.pages}${page.docPath}`}
|
|
95
89
|
target="_blank"
|
|
96
90
|
rel="noreferrer"
|
|
97
91
|
className="max-w-full truncate rounded-control text-accent hover:underline"
|
|
@@ -100,7 +94,7 @@ export function Starter({
|
|
|
100
94
|
current catalog page
|
|
101
95
|
</a>
|
|
102
96
|
) : null}
|
|
103
|
-
</div>
|
|
97
|
+
</div> : null}
|
|
104
98
|
</div>
|
|
105
99
|
) : null}
|
|
106
100
|
<div className="label mt-5 mb-1">try one</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { catalogDocs } from "../catalog-docs";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
vi.doUnmock("../data");
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
{ id: "default", generate: [{ plugin: "markdown", out: "docs" }], index: "/portolan/llms.txt", pages: "/portolan/docs/" },
|
|
12
|
+
{ id: "example", generate: [
|
|
13
|
+
{ plugin: "markdown", catalog: "portolan", out: "docs" },
|
|
14
|
+
{ plugin: "markdown", catalog: "example", out: "docs/example" },
|
|
15
|
+
], index: "/portolan/docs/example/llms.txt", pages: "/portolan/docs/example/" },
|
|
16
|
+
])("loads the index and catalog pages for $id", async ({ id, generate, index, pages }) => {
|
|
17
|
+
vi.doMock("../data", () => ({ activeCatalogDocs: catalogDocs({ generate }, id, "/portolan/") }));
|
|
18
|
+
const fetcher = vi.fn().mockResolvedValue(new Response("# Catalog documentation"));
|
|
19
|
+
vi.stubGlobal("fetch", fetcher);
|
|
20
|
+
const tools = await import("./tools");
|
|
21
|
+
|
|
22
|
+
expect(await tools.loadIndex()).toBe("# Catalog documentation");
|
|
23
|
+
expect(fetcher).toHaveBeenLastCalledWith(index);
|
|
24
|
+
fetcher.mockResolvedValue(new Response("# Auth service"));
|
|
25
|
+
expect(await tools.readPage("docs/auth/README.md")).toBe("# Auth service");
|
|
26
|
+
expect(fetcher).toHaveBeenLastCalledWith(`${pages}auth/README.md`);
|
|
27
|
+
});
|
package/src/chat/tools.ts
CHANGED
|
@@ -6,19 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { jsonSchema, tool } from "ai";
|
|
8
8
|
import { clipPage, pagePath, TOOL_SPECS } from "./prompt";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
const base = import.meta.env.BASE_URL;
|
|
12
|
-
const profileSuffix =
|
|
13
|
-
activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
|
|
14
|
-
const docsBase = `${base}docs/${profileSuffix}`;
|
|
15
|
-
const indexUrl = profileSuffix ? `${docsBase}llms.txt` : `${base}llms.txt`;
|
|
9
|
+
import { activeCatalogDocs } from "../data";
|
|
16
10
|
|
|
17
11
|
/** One page of the generated docs, as the model gets it. */
|
|
18
12
|
export async function readPage(raw: unknown): Promise<string> {
|
|
19
13
|
const path = pagePath(raw);
|
|
20
14
|
if (!path) return "That is not a page of the catalog. Use a path from the index.";
|
|
21
|
-
|
|
15
|
+
if (!activeCatalogDocs) return "No documentation is configured for this catalog.";
|
|
16
|
+
const response = await fetch(`${activeCatalogDocs.pages}${path.slice("docs/".length)}`);
|
|
22
17
|
if (!response.ok) return `No page at ${path}. Use a path from the index.`;
|
|
23
18
|
return clipPage(await response.text());
|
|
24
19
|
}
|
|
@@ -26,7 +21,8 @@ export async function readPage(raw: unknown): Promise<string> {
|
|
|
26
21
|
/** llms.txt, once per session. */
|
|
27
22
|
let indexPromise: Promise<string> | null = null;
|
|
28
23
|
export function loadIndex(): Promise<string> {
|
|
29
|
-
|
|
24
|
+
if (!activeCatalogDocs) return Promise.reject(new Error("No documentation is configured for this catalog."));
|
|
25
|
+
indexPromise ??= fetch(activeCatalogDocs.index).then((response) => {
|
|
30
26
|
if (!response.ok) {
|
|
31
27
|
indexPromise = null;
|
|
32
28
|
throw new Error("the catalog index (llms.txt) could not be read");
|
|
@@ -6,23 +6,25 @@ import { absoluteTime, plural, relativeTime } from "../lib/format";
|
|
|
6
6
|
/**
|
|
7
7
|
* Where the catalog on this page came from, and when.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* A source is dated by the commit that last changed it, read off the
|
|
10
|
+
* checkout's history rather than out of the file (portolan.0010). The header
|
|
11
|
+
* says one date and one commit, and both are summaries of a corpus: the date
|
|
12
|
+
* is the OLDEST of the sources, because a merged catalog is exactly as fresh
|
|
13
|
+
* as its stalest part, and the commit is a count whenever the sources do not
|
|
14
|
+
* agree on one. A summary of many numbers is the right thing to put in a
|
|
15
|
+
* header and the wrong thing to leave a reader with - "6 sources · 15 hours
|
|
14
16
|
* ago" answers "is this current" and refuses "which part is not", which is the
|
|
15
17
|
* question anyone who did not like the first answer asks next.
|
|
16
18
|
*
|
|
17
19
|
* So the stamp opens. Behind it is one row per commit, newest first, and the
|
|
18
20
|
* row the header is quoting says so. Rows are per COMMIT and not per file
|
|
19
|
-
* because twenty-eight fragments
|
|
21
|
+
* because twenty-eight fragments changed by one commit are one source of
|
|
20
22
|
* facts, and it is the same count the header prints.
|
|
21
23
|
*/
|
|
22
24
|
export function CatalogStamp() {
|
|
23
25
|
const groups = byCommit(catalogSources);
|
|
24
26
|
const authored = catalogSources.filter((source) => !source.commit);
|
|
25
|
-
const summary = `catalog
|
|
27
|
+
const summary = `catalog last changed ${absoluteTime(catalog.generatedAt)}, ${catalog.commit}`;
|
|
26
28
|
|
|
27
29
|
return (
|
|
28
30
|
<Popover className="ml-auto">
|
|
@@ -43,7 +45,7 @@ export function CatalogStamp() {
|
|
|
43
45
|
>
|
|
44
46
|
<div className="label mb-1.5 px-1">
|
|
45
47
|
{groups.length} {plural(groups.length, "source")}, by the commit each
|
|
46
|
-
|
|
48
|
+
last changed in
|
|
47
49
|
</div>
|
|
48
50
|
<dl className="mono grid grid-cols-[auto_1fr_auto] items-baseline gap-x-3 gap-y-1 px-1">
|
|
49
51
|
{groups.map((group, i) => (
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { MemoryRouter } from "react-router";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { Channel } from "../catalog";
|
|
5
|
+
import { ChannelRows } from "./ChannelRows";
|
|
6
|
+
|
|
7
|
+
describe("ChannelRows MessagePack presentation", () => {
|
|
8
|
+
it("shows a machine-readable encoding with its declared content type", () => {
|
|
9
|
+
const channel: Channel = {
|
|
10
|
+
address: "inventory.snapshots",
|
|
11
|
+
kind: "message",
|
|
12
|
+
messages: [{
|
|
13
|
+
name: "inventory.Snapshot",
|
|
14
|
+
direction: "send",
|
|
15
|
+
encoding: "msgpack",
|
|
16
|
+
contentType: "application/msgpack",
|
|
17
|
+
}],
|
|
18
|
+
};
|
|
19
|
+
const markup = renderToStaticMarkup(
|
|
20
|
+
<MemoryRouter>
|
|
21
|
+
<ChannelRows channels={[channel]} service="shop.inventory" />
|
|
22
|
+
</MemoryRouter>,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(markup).toContain('title="application/msgpack"');
|
|
26
|
+
expect(markup).toContain(">msgpack</span>");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { MemoryRouter } from "react-router";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { Channel } from "../catalog";
|
|
5
|
+
import { ChannelRowsContent } from "./ChannelRows";
|
|
6
|
+
|
|
7
|
+
const kafka: Channel = {
|
|
8
|
+
address: "orders/created",
|
|
9
|
+
kind: "message",
|
|
10
|
+
title: "Kafka · orders/created",
|
|
11
|
+
messages: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const nats: Channel = {
|
|
15
|
+
address: "orders.created",
|
|
16
|
+
kind: "message",
|
|
17
|
+
title: "JetStream subject",
|
|
18
|
+
messages: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("ChannelRows Kafka UI integration", () => {
|
|
22
|
+
it("links only Kafka cards to their exact topic when configured", () => {
|
|
23
|
+
const markup = renderToStaticMarkup(
|
|
24
|
+
<MemoryRouter>
|
|
25
|
+
<ChannelRowsContent
|
|
26
|
+
channels={[kafka, nats]}
|
|
27
|
+
service="shop.orders"
|
|
28
|
+
kafkaUi="https://ops.example/ui/clusters/prod"
|
|
29
|
+
/>
|
|
30
|
+
</MemoryRouter>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(markup).toContain("view in Kafka UI");
|
|
34
|
+
expect(markup).toContain(
|
|
35
|
+
'href="https://ops.example/ui/clusters/prod/all-topics/orders%2Fcreated"',
|
|
36
|
+
);
|
|
37
|
+
expect(markup.match(/view in Kafka UI/g)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not offer an operational link before Kafka UI is configured", () => {
|
|
41
|
+
const markup = renderToStaticMarkup(
|
|
42
|
+
<MemoryRouter>
|
|
43
|
+
<ChannelRowsContent
|
|
44
|
+
channels={[kafka]}
|
|
45
|
+
service="shop.orders"
|
|
46
|
+
kafkaUi=""
|
|
47
|
+
/>
|
|
48
|
+
</MemoryRouter>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(markup).not.toContain("view in Kafka UI");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
});
|
|
@@ -13,13 +13,22 @@
|
|
|
13
13
|
// Problems page says which; a row that quietly dropped it would be the site
|
|
14
14
|
// hiding the interesting case.
|
|
15
15
|
|
|
16
|
+
import { ExternalLink } from "lucide-react";
|
|
16
17
|
import { Link } from "react-router";
|
|
17
18
|
import type { Channel, ChannelMessage } from "../catalog";
|
|
18
|
-
import { index } from "../data";
|
|
19
|
+
import { catalog, index } from "../data";
|
|
20
|
+
import {
|
|
21
|
+
isKafkaChannel,
|
|
22
|
+
kafkaHandoffChannels,
|
|
23
|
+
kafkaUiTopicUrl,
|
|
24
|
+
useKafkaUi,
|
|
25
|
+
} from "../lib/kafka-ui";
|
|
19
26
|
import { eventPath } from "../routes";
|
|
20
27
|
import { Ident } from "./Ident";
|
|
21
28
|
import { RowActions } from "./RowActions";
|
|
22
29
|
|
|
30
|
+
const KAFKA_HANDOFF_CHANNELS = kafkaHandoffChannels(catalog);
|
|
31
|
+
|
|
23
32
|
/** The event that goes out under a wire name, when the catalog knows one. */
|
|
24
33
|
function publisherOf(name: string) {
|
|
25
34
|
const event = index.eventByWireName.get(name);
|
|
@@ -75,6 +84,11 @@ function MessageRow({
|
|
|
75
84
|
{elsewhere}
|
|
76
85
|
</span>
|
|
77
86
|
) : null}
|
|
87
|
+
{message.encoding || message.contentType ? (
|
|
88
|
+
<span className="chip mono" title={message.contentType || "payload encoding"}>
|
|
89
|
+
{message.encoding || message.contentType}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
78
92
|
</div>
|
|
79
93
|
{message.title || message.doc ? (
|
|
80
94
|
<p className="mt-0.5 text-muted">{message.doc || message.title}</p>
|
|
@@ -91,20 +105,53 @@ export function ChannelRows({
|
|
|
91
105
|
}: {
|
|
92
106
|
channels: Channel[];
|
|
93
107
|
service: string;
|
|
108
|
+
}) {
|
|
109
|
+
const kafkaUi = useKafkaUi((state) => state.url);
|
|
110
|
+
return (
|
|
111
|
+
<ChannelRowsContent
|
|
112
|
+
channels={channels}
|
|
113
|
+
service={service}
|
|
114
|
+
kafkaUi={kafkaUi}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The presentational half is exported so the generated links can be rendered in isolation. */
|
|
120
|
+
export function ChannelRowsContent({
|
|
121
|
+
channels,
|
|
122
|
+
service,
|
|
123
|
+
kafkaUi,
|
|
124
|
+
}: {
|
|
125
|
+
channels: Channel[];
|
|
126
|
+
service: string;
|
|
127
|
+
kafkaUi: string;
|
|
94
128
|
}) {
|
|
95
129
|
return (
|
|
96
130
|
<div className="flex flex-col gap-section" data-nav-list>
|
|
97
131
|
{channels.map((channel) => (
|
|
98
132
|
<div key={channel.address} className="rounded-card border border-line">
|
|
99
|
-
<div className="flex flex-wrap items-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-line px-3 py-2">
|
|
134
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2">
|
|
135
|
+
<Ident value={channel.address} />
|
|
136
|
+
{channel.kind === "job" ? (
|
|
137
|
+
<span className="chip">work queue</span>
|
|
138
|
+
) : channel.kind === "message" ? (
|
|
139
|
+
<span className="chip">message stream</span>
|
|
140
|
+
) : null}
|
|
141
|
+
{channel.title ? (
|
|
142
|
+
<span className="text-muted">{channel.title}</span>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
{kafkaUi && isKafkaChannel(channel, KAFKA_HANDOFF_CHANNELS) ? (
|
|
146
|
+
<a
|
|
147
|
+
href={kafkaUiTopicUrl(kafkaUi, channel.address) ?? kafkaUi}
|
|
148
|
+
target="_blank"
|
|
149
|
+
rel="noreferrer"
|
|
150
|
+
className="mono inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-control text-accent hover:underline"
|
|
151
|
+
title={`Open ${channel.address} in Kafka UI`}
|
|
152
|
+
>
|
|
153
|
+
view in Kafka UI <ExternalLink size={12} aria-hidden />
|
|
154
|
+
</a>
|
|
108
155
|
) : null}
|
|
109
156
|
</div>
|
|
110
157
|
{channel.doc ? (
|