@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.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. 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 installs architecture checks and static catalog publishing in one
205
- step. Existing unmanaged workflow files are never overwritten.
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.positionals, workspace);
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
- const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
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
- ["portolan.json", Boolean(manifest), "required"],
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 = JSON.parse(readFileSync(resolve(workspace, "portolan.json"), "utf8"));
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 = JSON.parse(readFileSync(manifestPath, "utf8"));
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.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.97",
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.0",
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.94",
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]
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(name: str, title: str, doc: str, direction: str) -> Dict[str, Any]:
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 ("key_serializer", "value_serializer"):
249
- raw[kw.arg] = expression(kw.value)
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 = JSON.parse(readFileSync(resolve(INSTALL_ROOT, "portolan.json"), "utf8"));
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 = JSON.parse(readFileSync(manifestPath, "utf8"));
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({ sources: [missing], projects: [{ id: "billing" }], extract: [] }, null, 2)}\n`);
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
  });