@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
package/README.md
CHANGED
|
@@ -201,8 +201,13 @@ for against what is on `PATH`. The Docker image contains all of them.
|
|
|
201
201
|
|
|
202
202
|
While `portolan dev` is running, open **Settings → Delivery presets**. Portolan
|
|
203
203
|
detects GitHub or GitLab from the repository's `origin`, previews the exact CI
|
|
204
|
-
changes, and
|
|
205
|
-
|
|
204
|
+
changes, and generates only the jobs selected there. Architecture checks and
|
|
205
|
+
static catalog publishing are selected by default; pull-request architecture
|
|
206
|
+
diffs and GitHub SARIF annotations are opt-in. Check, review, and publication
|
|
207
|
+
live in separate GitHub workflows, while SARIF augments the review workflow, so
|
|
208
|
+
comment and code-scanning permissions are granted only when those capabilities
|
|
209
|
+
are enabled. Existing unmanaged workflow files are never overwritten, and
|
|
210
|
+
disabling a capability removes only files managed by Portolan.
|
|
206
211
|
|
|
207
212
|
### Run without installing Node or language toolchains
|
|
208
213
|
|
package/cli/portolan.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
21
|
import { InitError, commandWorks, init as runInit, isInteractive, promptAnswers, toolchainFor } from "./init.mjs";
|
|
22
|
+
import { loadManifest, readManifest } from "../scripts/manifest.mjs";
|
|
22
23
|
|
|
23
24
|
const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
25
|
const packageJson = JSON.parse(readFileSync(resolve(installRoot, "package.json"), "utf8"));
|
|
@@ -54,7 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
54
55
|
return dev(workspace, parsed);
|
|
55
56
|
case "diff":
|
|
56
57
|
if (!parsed.positionals[0]) fail("diff requires a base branch, tag, or commit");
|
|
57
|
-
return runScript("scripts/diff.mjs", parsed
|
|
58
|
+
return runScript("scripts/diff.mjs", diffArgs(parsed), workspace);
|
|
59
|
+
case "comment":
|
|
60
|
+
if (!parsed.positionals[0]) fail("comment requires a markdown file");
|
|
61
|
+
return runScript("scripts/forge-comment.mjs", [parsed.positionals[0]], workspace);
|
|
58
62
|
case "doctor":
|
|
59
63
|
return doctor(workspace);
|
|
60
64
|
default:
|
|
@@ -68,7 +72,7 @@ function parse(argv) {
|
|
|
68
72
|
const arg = argv[index];
|
|
69
73
|
if (arg === "--help" || arg === "-h") value.help = true;
|
|
70
74
|
else if (arg === "--version" || arg === "-v") value.version = true;
|
|
71
|
-
else if (["--cwd", "--output", "--base", "--host", "--port"].includes(arg)) {
|
|
75
|
+
else if (["--cwd", "--output", "--base", "--host", "--port", "--format", "--site", "--head"].includes(arg)) {
|
|
72
76
|
const next = argv[++index];
|
|
73
77
|
if (!next) fail(`${arg} requires a value`);
|
|
74
78
|
value[arg.slice(2)] = next;
|
|
@@ -80,6 +84,14 @@ function parse(argv) {
|
|
|
80
84
|
return value;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
function diffArgs(parsed) {
|
|
88
|
+
const args = [parsed.positionals[0]];
|
|
89
|
+
for (const name of ["format", "output", "site", "head"]) {
|
|
90
|
+
if (parsed[name]) args.push(`--${name}`, parsed[name]);
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
function help() {
|
|
84
96
|
console.log(`Portolan ${VERSION}
|
|
85
97
|
|
|
@@ -92,6 +104,7 @@ Commands:
|
|
|
92
104
|
check fail when committed generated files are out of date
|
|
93
105
|
build build the static site
|
|
94
106
|
diff BASE describe architecture changes against BASE
|
|
107
|
+
comment FILE post or update a pull-request comment from Markdown
|
|
95
108
|
doctor check the local runtime and project configuration
|
|
96
109
|
version print the CLI version
|
|
97
110
|
|
|
@@ -99,6 +112,9 @@ Options:
|
|
|
99
112
|
--cwd DIR project directory (default: current directory)
|
|
100
113
|
--output DIR build output (default: dist)
|
|
101
114
|
--base PATH deployed URL base (default: /)
|
|
115
|
+
--format TYPE diff format: markdown, json, or sarif
|
|
116
|
+
--site URL catalog URL linked from an architecture diff
|
|
117
|
+
--head BRANCH head branch linked from an architecture diff
|
|
102
118
|
--host HOST dev server host (default: 127.0.0.1)
|
|
103
119
|
--port PORT dev server port
|
|
104
120
|
--yes, -y init without questions: take every detected default`);
|
|
@@ -118,10 +134,24 @@ async function init(workspace, options) {
|
|
|
118
134
|
|
|
119
135
|
function doctor(workspace) {
|
|
120
136
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
121
|
-
|
|
137
|
+
let manifest = null;
|
|
138
|
+
let manifestProblems = [];
|
|
139
|
+
if (existsSync(manifestPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const loaded = loadManifest(manifestPath);
|
|
142
|
+
manifest = loaded.manifest;
|
|
143
|
+
manifestProblems = loaded.problems;
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
manifestProblems = [cause instanceof Error ? cause.message : String(cause)];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
122
148
|
const checks = [
|
|
123
149
|
["Node.js >= 24", Number(process.versions.node.split(".")[0]) >= 24, process.version],
|
|
124
|
-
[
|
|
150
|
+
[
|
|
151
|
+
"portolan.json",
|
|
152
|
+
Boolean(manifest) && manifestProblems.length === 0,
|
|
153
|
+
manifestProblems.length > 0 ? manifestProblems.join("; ") : "required",
|
|
154
|
+
],
|
|
125
155
|
["Git", commandWorks("git", ["--version"]), "used for deterministic source stamps"],
|
|
126
156
|
];
|
|
127
157
|
|
|
@@ -157,7 +187,7 @@ async function build(workspace, options) {
|
|
|
157
187
|
runNode(packageBin("vite", "bin/vite.js"), ["build", stage, "--config", resolve(stage, "vite.config.ts"), "--outDir", output, "--emptyOutDir"], workspace, env);
|
|
158
188
|
|
|
159
189
|
const { siteDocs } = await import(resolve(installRoot, "scripts/site-docs.mjs"));
|
|
160
|
-
const manifest =
|
|
190
|
+
const manifest = readManifest(resolve(workspace, "portolan.json"));
|
|
161
191
|
const written = siteDocs({ manifest, dist: output });
|
|
162
192
|
copyFileSync(resolve(output, "index.html"), resolve(output, "404.html"));
|
|
163
193
|
console.log(`site: ${relative(workspace, output) || "."}${written.length ? `; mounted ${written.join(", ")}` : ""}`);
|
|
@@ -178,7 +208,7 @@ async function dev(workspace, options) {
|
|
|
178
208
|
export async function prepareSite(workspace) {
|
|
179
209
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
180
210
|
if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
|
|
181
|
-
const manifest =
|
|
211
|
+
const manifest = readManifest(manifestPath);
|
|
182
212
|
const stage = resolve(workspace, ".portolan", "site");
|
|
183
213
|
rmSync(stage, { recursive: true, force: true });
|
|
184
214
|
mkdirSync(stage, { recursive: true });
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@shortlink-org/portolan",
|
|
3
3
|
"description": "Generate a navigable architecture catalog from code and specifications.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.4",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/shortlink-org/portolan#readme",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@ai-sdk/openai-compatible": "3.0.45",
|
|
71
|
-
"@ai-sdk/react": "4.0.
|
|
71
|
+
"@ai-sdk/react": "4.0.98",
|
|
72
72
|
"@asyncapi/react-component": "^3.1.8",
|
|
73
73
|
"@clack/prompts": "^1.8.0",
|
|
74
|
-
"@floating-ui/react": "^0.27.
|
|
74
|
+
"@floating-ui/react": "^0.27.20",
|
|
75
75
|
"@headlessui/react": "^2.2.10",
|
|
76
76
|
"@scalar/api-reference-react": "^0.9.66",
|
|
77
77
|
"@tailwindcss/vite": "^4.0.0",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@tanstack/react-virtual": "^3.14.10",
|
|
81
81
|
"@vitejs/plugin-react": "^6.1.1",
|
|
82
82
|
"@xyflow/react": "^12.11.5",
|
|
83
|
-
"ai": "7.0.
|
|
83
|
+
"ai": "7.0.95",
|
|
84
84
|
"ajv": "^8.20.0",
|
|
85
85
|
"elkjs": "^0.12.0",
|
|
86
86
|
"html-to-image": "^1.11.13",
|
package/plugins/README.md
CHANGED
|
@@ -1142,6 +1142,11 @@ live in another estate — would be the wrong end of the trade.
|
|
|
1142
1142
|
service declares — the address the broker knows, and each message on it with the
|
|
1143
1143
|
direction it travels. What it does **not** answer with is events.
|
|
1144
1144
|
|
|
1145
|
+
A message's `contentType`, or the document's `defaultContentType` when the
|
|
1146
|
+
message leaves it out, is kept as the exact declared media type. MessagePack
|
|
1147
|
+
media types are additionally normalized to `encoding: msgpack`, so source and
|
|
1148
|
+
contract extractors can be compared without depending on one MIME spelling.
|
|
1149
|
+
|
|
1145
1150
|
That looks like a gap and is a boundary. `Event.id` is
|
|
1146
1151
|
`<service>.<aggregate>.<Name>`, and an AsyncAPI document knows the message on the
|
|
1147
1152
|
wire, not the aggregate that raised it. An extractor that guessed would either
|
|
@@ -22,6 +22,12 @@ id, consumer group, key, headers and serializer facts. Authentication values
|
|
|
22
22
|
are never emitted. Partitions, replication and retention are broker-side facts
|
|
23
23
|
and remain explicitly unknown unless another catalog source declares them.
|
|
24
24
|
|
|
25
|
+
MessagePack serializers and deserializers declared through `msgpack` or
|
|
26
|
+
`messagepack` calls are normalized to `encoding: "msgpack"` on the affected
|
|
27
|
+
channel message. This includes constructor callbacks and payloads packed
|
|
28
|
+
directly before a publish; Portolan records the wire format without importing
|
|
29
|
+
or executing the codec.
|
|
30
|
+
|
|
25
31
|
```json
|
|
26
32
|
{
|
|
27
33
|
"plugin": "python-kafka",
|
|
@@ -99,7 +99,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
|
|
|
99
99
|
for item in sends:
|
|
100
100
|
key = (item.message, "send")
|
|
101
101
|
if key not in seen:
|
|
102
|
-
messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send"))
|
|
102
|
+
messages.append(catalog.message(item.message, title(item.message), message_doc(item), "send", item.encoding))
|
|
103
103
|
seen.add(key)
|
|
104
104
|
for item in receives:
|
|
105
105
|
# Kafka subscriptions dispatch records, and source often does not prove
|
|
@@ -108,7 +108,7 @@ def channel(topic: str, sends: List[kafka.Publish], receives: List[kafka.Subscri
|
|
|
108
108
|
name = sends[0].message if len({sent.message for sent in sends}) == 1 else "message"
|
|
109
109
|
key = (name, "receive")
|
|
110
110
|
if key not in seen:
|
|
111
|
-
messages.append(catalog.message(name, title(name), consumer_doc(item), "receive"))
|
|
111
|
+
messages.append(catalog.message(name, title(name), consumer_doc(item), "receive", item.client.encoding))
|
|
112
112
|
seen.add(key)
|
|
113
113
|
clients = []
|
|
114
114
|
for item in list(sends) + list(receives):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Framework-neutral Kafka extraction contracts."""
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import sys
|
|
@@ -9,6 +10,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
9
10
|
sys.path.insert(1, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "pyplugin"))
|
|
10
11
|
|
|
11
12
|
from extract import extract # noqa: E402
|
|
13
|
+
import kafka # noqa: E402
|
|
12
14
|
from options import Options # noqa: E402
|
|
13
15
|
from protocol import Builder, Input # noqa: E402
|
|
14
16
|
|
|
@@ -33,7 +35,7 @@ class PythonKafka(unittest.TestCase):
|
|
|
33
35
|
|
|
34
36
|
def test_three_standard_clients_form_message_channels(self):
|
|
35
37
|
channels = {item["address"]: item for item in self.service["channels"]}
|
|
36
|
-
self.assertEqual(sorted(channels), ["audit.records", "orders.created", "payments.accepted"])
|
|
38
|
+
self.assertEqual(sorted(channels), ["audit.records", "inventory.snapshots", "orders.created", "payments.accepted"])
|
|
37
39
|
self.assertTrue(all(item["kind"] == "message" for item in channels.values()))
|
|
38
40
|
self.assertEqual(
|
|
39
41
|
[message["direction"] for message in channels["orders.created"]["messages"]],
|
|
@@ -50,6 +52,20 @@ class PythonKafka(unittest.TestCase):
|
|
|
50
52
|
self.assertNotIn("sasl.password", self.contents)
|
|
51
53
|
self.assertNotIn("sasl.username", self.contents)
|
|
52
54
|
|
|
55
|
+
def test_messagepack_serializer_and_deserializer_are_machine_readable(self):
|
|
56
|
+
snapshots = next(item for item in self.service["channels"] if item["address"] == "inventory.snapshots")
|
|
57
|
+
self.assertEqual([message["encoding"] for message in snapshots["messages"]], ["msgpack", "msgpack"])
|
|
58
|
+
self.assertIn("value serializer: lambda value: msgpack.packb(value)", snapshots["doc"])
|
|
59
|
+
self.assertIn("value deserializer: msgpack.unpackb", snapshots["doc"])
|
|
60
|
+
|
|
61
|
+
def test_direct_messagepack_call_keeps_the_payload_name(self):
|
|
62
|
+
call = ast.parse("msgpack.packb(snapshot)", mode="eval").body
|
|
63
|
+
self.assertEqual(kafka.payload_name(call), "snapshot")
|
|
64
|
+
self.assertEqual(kafka.payload_encoding(call), "msgpack")
|
|
65
|
+
packer = ast.parse("msgpack.Packer()", mode="eval").body
|
|
66
|
+
method = ast.parse("packer.pack(snapshot)", mode="eval").body
|
|
67
|
+
self.assertEqual(kafka.payload_encoding(method, {"packer": ("expr", packer)}), "msgpack")
|
|
68
|
+
|
|
53
69
|
def test_publish_and_receive_flows_have_kafka_handoffs_and_source(self):
|
|
54
70
|
steps = [step for flow in self.fragment["flows"] for step in flow["steps"]]
|
|
55
71
|
handoffs = [step["handoff"] for step in steps]
|
package/plugins/portolan-go.wasm
CHANGED
|
Binary file
|
|
@@ -81,13 +81,24 @@ def channel(address: str, kind: str, title: str, doc: str, messages: List[Dict[s
|
|
|
81
81
|
return out
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def message(
|
|
84
|
+
def message(
|
|
85
|
+
name: str,
|
|
86
|
+
title: str,
|
|
87
|
+
doc: str,
|
|
88
|
+
direction: str,
|
|
89
|
+
encoding: str = "",
|
|
90
|
+
content_type: str = "",
|
|
91
|
+
) -> Dict[str, Any]:
|
|
85
92
|
out: Dict[str, Any] = {"name": name}
|
|
86
93
|
if title:
|
|
87
94
|
out["title"] = title
|
|
88
95
|
if doc:
|
|
89
96
|
out["doc"] = doc
|
|
90
97
|
out["direction"] = direction
|
|
98
|
+
if encoding:
|
|
99
|
+
out["encoding"] = encoding
|
|
100
|
+
if content_type:
|
|
101
|
+
out["contentType"] = content_type
|
|
91
102
|
return out
|
|
92
103
|
|
|
93
104
|
|
|
@@ -23,6 +23,8 @@ class Spec:
|
|
|
23
23
|
CONSTRUCTORS = {
|
|
24
24
|
"confluent_kafka.Producer": Spec("confluent-kafka", "producer"),
|
|
25
25
|
"confluent_kafka.Consumer": Spec("confluent-kafka", "consumer"),
|
|
26
|
+
"confluent_kafka.SerializingProducer": Spec("confluent-kafka", "producer"),
|
|
27
|
+
"confluent_kafka.DeserializingConsumer": Spec("confluent-kafka", "consumer"),
|
|
26
28
|
"kafka.KafkaProducer": Spec("kafka-python", "producer"),
|
|
27
29
|
"kafka.KafkaConsumer": Spec("kafka-python", "consumer"),
|
|
28
30
|
"aiokafka.AIOKafkaProducer": Spec("aiokafka", "producer"),
|
|
@@ -62,6 +64,23 @@ CONFIG_KEYS = {
|
|
|
62
64
|
"enable_auto_commit": "auto commit",
|
|
63
65
|
"key_serializer": "key serializer",
|
|
64
66
|
"value_serializer": "value serializer",
|
|
67
|
+
"key_deserializer": "key deserializer",
|
|
68
|
+
"value_deserializer": "value deserializer",
|
|
69
|
+
"key.serializer": "key serializer",
|
|
70
|
+
"value.serializer": "value serializer",
|
|
71
|
+
"key.deserializer": "key deserializer",
|
|
72
|
+
"value.deserializer": "value deserializer",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
SERDE_KEYS = {
|
|
76
|
+
"key_serializer",
|
|
77
|
+
"value_serializer",
|
|
78
|
+
"key_deserializer",
|
|
79
|
+
"value_deserializer",
|
|
80
|
+
"key.serializer",
|
|
81
|
+
"value.serializer",
|
|
82
|
+
"key.deserializer",
|
|
83
|
+
"value.deserializer",
|
|
65
84
|
}
|
|
66
85
|
|
|
67
86
|
|
|
@@ -73,6 +92,10 @@ class Client:
|
|
|
73
92
|
source: str = ""
|
|
74
93
|
constructor_topics: List[str] = field(default_factory=list)
|
|
75
94
|
|
|
95
|
+
@property
|
|
96
|
+
def encoding(self) -> str:
|
|
97
|
+
return encoding_of_text(" ".join(str(self.config.get(key, "")) for key in ("value serializer", "value deserializer")))
|
|
98
|
+
|
|
76
99
|
|
|
77
100
|
@dataclass
|
|
78
101
|
class Publish:
|
|
@@ -86,6 +109,7 @@ class Publish:
|
|
|
86
109
|
message_expression: str
|
|
87
110
|
key: str = ""
|
|
88
111
|
headers: str = ""
|
|
112
|
+
encoding: str = ""
|
|
89
113
|
|
|
90
114
|
@property
|
|
91
115
|
def line(self) -> str:
|
|
@@ -181,6 +205,8 @@ def value(
|
|
|
181
205
|
for key_node, val_node in zip(node.keys, node.values):
|
|
182
206
|
key = value(project, module, key_node, settings, variables, depth + 1)
|
|
183
207
|
val = value(project, module, val_node, settings, variables, depth + 1)
|
|
208
|
+
if val is None and isinstance(key, str) and key in SERDE_KEYS:
|
|
209
|
+
val = serialization_expression(module, val_node, variables)
|
|
184
210
|
if not isinstance(key, str) or val is None:
|
|
185
211
|
continue
|
|
186
212
|
out[key] = val
|
|
@@ -239,14 +265,21 @@ def _config_values(project: Project, module: Module, call: ast.Call, settings: s
|
|
|
239
265
|
first = value(project, module, call.args[0], settings, variables)
|
|
240
266
|
if isinstance(first, dict):
|
|
241
267
|
raw.update(first)
|
|
268
|
+
# Callable serializer values are not safe scalar values, but their
|
|
269
|
+
# syntax is still the evidence needed to identify MessagePack.
|
|
270
|
+
if isinstance(call.args[0], ast.Dict):
|
|
271
|
+
for key_node, val_node in zip(call.args[0].keys, call.args[0].values):
|
|
272
|
+
key = value(project, module, key_node, settings, variables)
|
|
273
|
+
if isinstance(key, str) and key in SERDE_KEYS and key not in raw:
|
|
274
|
+
raw[key] = serialization_expression(module, val_node, variables)
|
|
242
275
|
for kw in call.keywords:
|
|
243
276
|
if not kw.arg:
|
|
244
277
|
continue
|
|
245
278
|
resolved = value(project, module, kw.value, settings, variables)
|
|
246
279
|
if resolved is not None:
|
|
247
280
|
raw[kw.arg] = resolved
|
|
248
|
-
elif kw.arg in
|
|
249
|
-
raw[kw.arg] =
|
|
281
|
+
elif kw.arg in SERDE_KEYS:
|
|
282
|
+
raw[kw.arg] = serialization_expression(module, kw.value, variables)
|
|
250
283
|
out: Dict[str, Any] = {}
|
|
251
284
|
for key, val in raw.items():
|
|
252
285
|
normalized = CONFIG_KEYS.get(key)
|
|
@@ -377,7 +410,7 @@ def payload_name(node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[st
|
|
|
377
410
|
return node.id
|
|
378
411
|
if isinstance(node, ast.Call):
|
|
379
412
|
last = dotted(node.func).split(".")[-1]
|
|
380
|
-
if last in ("dumps", "dump", "encode", "serialize", "SerializeToString", "asdict", "dict", "model_dump") and node.args:
|
|
413
|
+
if last in ("dumps", "dump", "encode", "serialize", "SerializeToString", "asdict", "dict", "model_dump", "pack", "packb") and node.args:
|
|
381
414
|
return payload_name(node.args[0], variables, depth + 1)
|
|
382
415
|
return last or "message"
|
|
383
416
|
if isinstance(node, ast.Attribute):
|
|
@@ -387,6 +420,43 @@ def payload_name(node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[st
|
|
|
387
420
|
return "message"
|
|
388
421
|
|
|
389
422
|
|
|
423
|
+
def encoding_of_text(text: str) -> str:
|
|
424
|
+
lowered = text.lower()
|
|
425
|
+
if "msgpack" in lowered or "messagepack" in lowered:
|
|
426
|
+
return "msgpack"
|
|
427
|
+
return ""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def serialization_expression(module: Optional[Module], node: Optional[ast.AST], variables: Optional[Dict[str, Tuple[str, object]]] = None) -> str:
|
|
431
|
+
if node is None:
|
|
432
|
+
return ""
|
|
433
|
+
parts = [expression(node)]
|
|
434
|
+
if module is not None:
|
|
435
|
+
parts.append(external_name(module, dotted(node.func) if isinstance(node, ast.Call) else dotted(node)))
|
|
436
|
+
owner = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else (node.value if isinstance(node, ast.Attribute) else None)
|
|
437
|
+
if isinstance(owner, ast.Name):
|
|
438
|
+
bound = (variables or {}).get(owner.id)
|
|
439
|
+
if bound is not None and bound[0] == "expr" and isinstance(bound[1], ast.AST):
|
|
440
|
+
parts.append(expression(bound[1]))
|
|
441
|
+
return " ".join(filter(None, parts))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def payload_encoding(
|
|
445
|
+
node: Optional[ast.AST],
|
|
446
|
+
variables: Optional[Dict[str, Tuple[str, object]]] = None,
|
|
447
|
+
depth: int = 0,
|
|
448
|
+
module: Optional[Module] = None,
|
|
449
|
+
) -> str:
|
|
450
|
+
if node is None or depth > 6:
|
|
451
|
+
return ""
|
|
452
|
+
variables = variables or {}
|
|
453
|
+
if isinstance(node, ast.Name):
|
|
454
|
+
bound = variables.get(node.id)
|
|
455
|
+
if bound is not None and bound[0] == "expr" and isinstance(bound[1], ast.AST):
|
|
456
|
+
return payload_encoding(bound[1], variables, depth + 1, module)
|
|
457
|
+
return encoding_of_text(serialization_expression(module, node, variables))
|
|
458
|
+
|
|
459
|
+
|
|
390
460
|
def _text(node: Optional[ast.AST]) -> str:
|
|
391
461
|
text = expression(node)
|
|
392
462
|
return text if len(text) <= 100 else text[:97] + "..."
|
|
@@ -541,6 +611,7 @@ class Scanner:
|
|
|
541
611
|
_text(payload),
|
|
542
612
|
_text(keyword(call, "key")),
|
|
543
613
|
_text(keyword(call, "headers")),
|
|
614
|
+
payload_encoding(payload, env, module=module) or client.encoding,
|
|
544
615
|
)
|
|
545
616
|
)
|
|
546
617
|
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// package happens to run them. Custom plugins may still be declared in the
|
|
5
5
|
// manifest with `process` or `wasm`; those declarations always win.
|
|
6
6
|
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
7
|
import { dirname, resolve } from "node:path";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
9
|
|
|
10
|
+
import { readManifest } from "./manifest.mjs";
|
|
11
|
+
|
|
11
12
|
export const INSTALL_ROOT = process.env.PORTOLAN_INSTALL_ROOT
|
|
12
13
|
? resolve(process.env.PORTOLAN_INSTALL_ROOT)
|
|
13
14
|
: resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -16,7 +17,7 @@ let definitions;
|
|
|
16
17
|
|
|
17
18
|
function shippedDefinitions() {
|
|
18
19
|
if (definitions) return definitions;
|
|
19
|
-
const manifest =
|
|
20
|
+
const manifest = readManifest(resolve(INSTALL_ROOT, "portolan.json"));
|
|
20
21
|
definitions = new Map((manifest.plugins ?? []).map((plugin) => [plugin.name, plugin]));
|
|
21
22
|
return definitions;
|
|
22
23
|
}
|
|
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
|
|
|
14
14
|
import { filterCatalogForProfile } from "../src/catalog-profile.ts";
|
|
15
15
|
import { enrichCatalog } from "../src/enrich.ts";
|
|
16
16
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
17
|
+
import { readManifest } from "./manifest.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* `exclude` names source files to leave out, as the manifest would spell
|
|
@@ -22,7 +23,7 @@ import { mergeCatalogs } from "../src/merge.ts";
|
|
|
22
23
|
* what it wrote last time would count as what it saw this time.
|
|
23
24
|
*/
|
|
24
25
|
export async function loadCatalog(manifestPath = "portolan.json", { exclude = [], profile } = {}) {
|
|
25
|
-
const manifest =
|
|
26
|
+
const manifest = readManifest(manifestPath);
|
|
26
27
|
const excluded = new Set(exclude.map((path) => normalize(path)));
|
|
27
28
|
const selected = profile
|
|
28
29
|
? (manifest.catalogs ?? []).find((candidate) => candidate.id === profile)
|
|
@@ -29,7 +29,17 @@ describe("catalog sources", () => {
|
|
|
29
29
|
|
|
30
30
|
it("still reports missing sources for a configured project", async () => {
|
|
31
31
|
const { path, missing } = manifest({ sources: [], projects: [] });
|
|
32
|
-
writeFileSync(path, `${JSON.stringify({
|
|
32
|
+
writeFileSync(path, `${JSON.stringify({
|
|
33
|
+
sources: [missing],
|
|
34
|
+
projects: [{
|
|
35
|
+
id: "billing",
|
|
36
|
+
name: "Billing",
|
|
37
|
+
root: "services/billing",
|
|
38
|
+
context: "shop",
|
|
39
|
+
service: "billing",
|
|
40
|
+
}],
|
|
41
|
+
extract: [],
|
|
42
|
+
}, null, 2)}\n`);
|
|
33
43
|
await expect(loadCatalog(path)).rejects.toThrow(/no catalog matched/);
|
|
34
44
|
});
|
|
35
45
|
});
|