@shortlink-org/portolan 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,176 @@
1
+ // A recording uploaded from the page becomes a file beside the project and a
2
+ // verify step that reads it - nothing else. The pure half of that lives here,
3
+ // where a test can hold it: which step reads a project's recordings, where an
4
+ // upload lands, and what a trial run said about it once the verifier ran.
5
+ //
6
+ // The recording is kept in the repository on purpose (portolan.0014). The
7
+ // catalog is regenerated from files, by anyone, on any machine; a recording
8
+ // held anywhere else would verify a flow once and never again.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join, posix } from "node:path";
12
+
13
+ export const TRACE_PLUGIN = "otel";
14
+ export const RECORDINGS_DIR = "telemetry/recordings";
15
+ export const RECORDINGS_GLOB = `${RECORDINGS_DIR}/*.jsonl`;
16
+ export const UPLOAD_LIMIT = 32 * 1024 * 1024;
17
+
18
+ /** The verify step that reads a project's recordings, or null. */
19
+ export function traceStepFor(manifest, project) {
20
+ const steps = (manifest.verify ?? []).map((step, index) => ({ step, index }));
21
+ const mine = steps.filter(({ step }) => step.plugin === TRACE_PLUGIN && step.in === project.root);
22
+ return mine[0] ?? null;
23
+ }
24
+
25
+ /**
26
+ * The manifest with a step that reads the project's recordings: the one it
27
+ * has, widened to the recordings directory when it does not look there yet,
28
+ * or a new one after the last verify step. Says whether anything changed.
29
+ */
30
+ export function manifestWithTraceStep(manifest, project) {
31
+ const found = traceStepFor(manifest, project);
32
+ const verify = [...(manifest.verify ?? [])];
33
+ if (found) {
34
+ const traces = found.step.options?.traces ?? [];
35
+ if (traces.includes(RECORDINGS_GLOB)) return { manifest, changed: false, change: "none", step: found.step };
36
+ const step = { ...found.step, options: { ...found.step.options, traces: [...traces, RECORDINGS_GLOB] } };
37
+ verify[found.index] = step;
38
+ return { manifest: { ...manifest, verify }, changed: true, change: "widened", step };
39
+ }
40
+ const step = {
41
+ plugin: TRACE_PLUGIN,
42
+ in: project.root,
43
+ out: posix.join(project.root, "portolan"),
44
+ options: { traces: [RECORDINGS_GLOB], out: "observed.json" },
45
+ };
46
+ verify.push(step);
47
+ return { manifest: { ...manifest, verify }, changed: true, change: "added", step };
48
+ }
49
+
50
+ /** The step with the names the page was told to map, merged over what it had. */
51
+ export function stepWithMappings(step, { services, events, routes } = {}) {
52
+ const options = { ...step.options };
53
+ if (services && Object.keys(services).length) options.services = { ...options.services, ...services };
54
+ if (events && Object.keys(events).length) options.events = { ...options.events, ...events };
55
+ if (routes && Object.keys(routes).length) options.routes = { ...options.routes, ...routes };
56
+ return { ...step, options };
57
+ }
58
+
59
+ /**
60
+ * Where an upload lands, relative to the project root: dated, named after
61
+ * the file it came as, and not on top of a recording already there.
62
+ */
63
+ export function recordingPath(name, { today = new Date(), taken = () => false } = {}) {
64
+ const base = String(name ?? "").split(/[\\/]/).pop() ?? "";
65
+ const stem = base.replace(/\.(jsonl?|ndjson)$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "recording";
66
+ const date = today.toISOString().slice(0, 10);
67
+ let candidate = `${RECORDINGS_DIR}/${date}-${stem}.jsonl`;
68
+ for (let n = 2; taken(candidate); n += 1) candidate = `${RECORDINGS_DIR}/${date}-${stem}-${n}.jsonl`;
69
+ return candidate;
70
+ }
71
+
72
+ /**
73
+ * Says whether the bytes are a recording the verifier can read: OTLP JSON,
74
+ * one batch per file or one per line, with resourceSpans in it.
75
+ */
76
+ export function checkRecording(content) {
77
+ const text = content.toString("utf8");
78
+ if (!text.trim()) throw new Error("The recording is empty.");
79
+ let batches = 0;
80
+ let spans = 0;
81
+ const read = (value) => {
82
+ if (!value || typeof value !== "object" || !Array.isArray(value.resourceSpans)) throw new Error("The recording is JSON, but not OTLP: no resourceSpans in it.");
83
+ batches += 1;
84
+ for (const rs of value.resourceSpans) for (const ss of rs?.scopeSpans ?? []) spans += (ss?.spans ?? []).length;
85
+ };
86
+ try {
87
+ read(JSON.parse(text));
88
+ } catch (cause) {
89
+ if (cause instanceof SyntaxError) {
90
+ for (const line of text.split("\n")) {
91
+ if (!line.trim()) continue;
92
+ let value;
93
+ try { value = JSON.parse(line); } catch { throw new Error("The recording is not OTLP JSON: a collector's file exporter writes one batch per line."); }
94
+ read(value);
95
+ }
96
+ } else {
97
+ throw cause;
98
+ }
99
+ }
100
+ if (!spans) throw new Error("The recording has no spans in it.");
101
+ return { batches, spans };
102
+ }
103
+
104
+ /** What a verifier's warning is about, so the page can offer the mapping it asks for. */
105
+ export function readVerifyWarning(message) {
106
+ let m = message.match(/spans from service\.name "([^"]+)" match no service/);
107
+ if (m) return { kind: "service", name: m[1], message };
108
+ m = message.match(/(?:publishes|consumes) "([^"]+)", which matches no event/);
109
+ if (m) return { kind: "event", name: m[1], message };
110
+ m = message.match(/answers on ([A-Z]* ?\S+), which no interface it provides declares/);
111
+ if (m) return { kind: "route", name: m[1].trim(), message };
112
+ m = message.match(/calls ([A-Z]+ \S+) on (\S+), which no service/);
113
+ if (m) return { kind: "call", name: m[1], message };
114
+ if (/but the catalog says it goes on/.test(message)) return { kind: "channel", message };
115
+ return { kind: "other", message };
116
+ }
117
+
118
+ /**
119
+ * What the trial run said about the recording: the flows the verifier wrote
120
+ * with it, and the warnings that name something the page could map.
121
+ */
122
+ export function summarizeTraceTrial(snapshot, trial, events) {
123
+ const step = trial.step;
124
+ const finished = events.find((event) => event.type === "step-finished" && event.phase === "verify" && event.plugin === step.plugin && event.output === step.out && (event.input ?? step.in) === step.in)
125
+ ?? events.find((event) => event.type === "step-finished" && event.phase === "verify" && event.plugin === step.plugin && event.output === step.out);
126
+ const warnings = (finished?.warnings ?? []).map(readVerifyWarning);
127
+ const fragmentName = step.options?.out ?? "observed.json";
128
+ let fragment = { flows: [] };
129
+ try { fragment = JSON.parse(readFileSync(join(snapshot, step.out, fragmentName), "utf8")); } catch {}
130
+ // An example names its recording relative to the repository, the way every
131
+ // `source` in the catalog does; the upload was laid under the step's input.
132
+ const recording = posix.join(step.in, trial.recording);
133
+ const flows = [];
134
+ for (const flow of fragment.flows ?? []) {
135
+ const examples = (flow.examples ?? []).filter((example) => example.recording === recording);
136
+ const steps = [];
137
+ const walk = (nodes) => {
138
+ for (const node of nodes ?? []) {
139
+ if (node.type === "step") steps.push(node);
140
+ else if (node.type === "alt") for (const branch of node.branches ?? []) walk(branch.steps);
141
+ else if (node.type === "parallel") for (const branch of node.branches ?? []) walk(branch);
142
+ else walk(node.steps);
143
+ }
144
+ };
145
+ walk(flow.steps);
146
+ const shown = new Set(examples.flatMap((example) => example.steps.map((s) => s.step)));
147
+ const observed = String(flow.slug ?? "").startsWith("observed-");
148
+ flows.push({
149
+ id: flow.id,
150
+ slug: flow.slug,
151
+ name: flow.name,
152
+ owner: flow.owner,
153
+ kind: observed ? "observed" : "declared",
154
+ inRecording: examples.length > 0,
155
+ traces: examples.length,
156
+ verified: steps.filter((s) => s.status === "verified").length,
157
+ unresolved: steps.filter((s) => s.status === "unresolved").length,
158
+ added: steps.filter((s) => s.seen && !observed && /^seen\d+$/.test(String(s.id))).length,
159
+ shown: shown.size,
160
+ steps: steps.length,
161
+ examples: (flow.examples ?? []).length,
162
+ });
163
+ }
164
+ flows.sort((a, b) => Number(b.inRecording) - Number(a.inRecording) || a.slug.localeCompare(b.slug));
165
+ return {
166
+ project: trial.projectId,
167
+ recording: trial.recording,
168
+ stepAdded: trial.stepAdded,
169
+ stepChange: trial.stepChange ?? (trial.stepAdded ? "added" : "none"),
170
+ status: finished?.status ?? "failed",
171
+ spans: trial.spans,
172
+ flows,
173
+ warnings,
174
+ mappings: { services: step.options?.services ?? {}, events: step.options?.events ?? {}, routes: step.options?.routes ?? {} },
175
+ };
176
+ }
@@ -0,0 +1,142 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { checkRecording, manifestWithTraceStep, readVerifyWarning, recordingPath, stepWithMappings, summarizeTraceTrial, traceStepFor } from "./trace-trials.mjs";
7
+
8
+ const project = { id: "auth", name: "Auth", root: "examples/auth", context: "auth", service: "auth" };
9
+
10
+ describe("the step that reads a project's recordings", () => {
11
+ it("is added after the last verify step when the project has none", () => {
12
+ const manifest = { sources: ["x/*.json"], projects: [project], verify: [{ plugin: "codeowners", in: ".github", out: "data" }] };
13
+ const next = manifestWithTraceStep(manifest, project);
14
+
15
+ expect(next.changed).toBe(true);
16
+ expect(next.change).toBe("added");
17
+ expect(next.manifest.verify).toHaveLength(2);
18
+ expect(next.step).toEqual({ plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/recordings/*.jsonl"], out: "observed.json" } });
19
+ expect(manifest.verify).toHaveLength(1);
20
+ });
21
+
22
+ it("is widened to the recordings directory when it reads elsewhere, and left alone when it already does", () => {
23
+ const step = { plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/traces.jsonl"], out: "observed.json", services: { "auth-api": "auth.auth" } } };
24
+ const manifest = { sources: [], projects: [project], verify: [step] };
25
+
26
+ const widened = manifestWithTraceStep(manifest, project);
27
+ expect(widened.changed).toBe(true);
28
+ expect(widened.change).toBe("widened");
29
+ expect(widened.step.options.traces).toEqual(["telemetry/traces.jsonl", "telemetry/recordings/*.jsonl"]);
30
+ expect(widened.step.options.services).toEqual({ "auth-api": "auth.auth" });
31
+
32
+ const again = manifestWithTraceStep(widened.manifest, project);
33
+ expect(again.changed).toBe(false);
34
+ expect(again.change).toBe("none");
35
+ expect(traceStepFor(again.manifest, project)?.index).toBe(0);
36
+ });
37
+
38
+ it("is the project's own, not another project's", () => {
39
+ const other = { plugin: "otel", in: "examples/shop/cart", out: "examples/shop/cart/portolan", options: { traces: ["telemetry/traces.jsonl"] } };
40
+ expect(traceStepFor({ verify: [other] }, project)).toBeNull();
41
+ });
42
+
43
+ it("takes the names the page mapped over what it had", () => {
44
+ const step = { plugin: "otel", in: "x", out: "y", options: { traces: ["a"], events: { "a.B": "x.y.B" } } };
45
+ const mapped = stepWithMappings(step, { services: { "auth-api": "auth.auth" }, events: { "a.C": "x.y.C" }, routes: { "POST /api/v1/sessions": "login" } });
46
+ expect(mapped.options).toEqual({ traces: ["a"], events: { "a.B": "x.y.B", "a.C": "x.y.C" }, services: { "auth-api": "auth.auth" }, routes: { "POST /api/v1/sessions": "login" } });
47
+ expect(stepWithMappings(step, {})).toEqual(step);
48
+ });
49
+ });
50
+
51
+ describe("where an upload lands", () => {
52
+ const today = new Date("2026-09-12T10:00:00Z");
53
+
54
+ it("is dated, named after the file, and never on top of another", () => {
55
+ expect(recordingPath("Login Traces.JSONL", { today })).toBe("telemetry/recordings/2026-09-12-login-traces.jsonl");
56
+ expect(recordingPath("/tmp/export/traces.json", { today })).toBe("telemetry/recordings/2026-09-12-traces.jsonl");
57
+ expect(recordingPath("", { today })).toBe("telemetry/recordings/2026-09-12-recording.jsonl");
58
+ const taken = new Set(["telemetry/recordings/2026-09-12-traces.jsonl", "telemetry/recordings/2026-09-12-traces-2.jsonl"]);
59
+ expect(recordingPath("traces.jsonl", { today, taken: (candidate) => taken.has(candidate) })).toBe("telemetry/recordings/2026-09-12-traces-3.jsonl");
60
+ });
61
+ });
62
+
63
+ describe("what an upload has to be", () => {
64
+ const batch = (spans) => JSON.stringify({ resourceSpans: [{ resource: { attributes: [] }, scopeSpans: [{ spans }] }] });
65
+ const span = { traceId: "t", spanId: "s", name: "GET /", kind: 2 };
66
+
67
+ it("reads one value or one per line", () => {
68
+ expect(checkRecording(Buffer.from(batch([span])))).toEqual({ batches: 1, spans: 1 });
69
+ expect(checkRecording(Buffer.from(`${batch([span])}\n${batch([span, span])}\n`))).toEqual({ batches: 2, spans: 3 });
70
+ });
71
+
72
+ it("refuses what the verifier could not read, and says why", () => {
73
+ expect(() => checkRecording(Buffer.from(""))).toThrow(/empty/);
74
+ expect(() => checkRecording(Buffer.from("not json"))).toThrow(/not OTLP JSON/);
75
+ expect(() => checkRecording(Buffer.from('{"traces":[]}'))).toThrow(/no resourceSpans/);
76
+ expect(() => checkRecording(Buffer.from(batch([])))).toThrow(/no spans/);
77
+ });
78
+ });
79
+
80
+ describe("what a verifier's warning is about", () => {
81
+ it("names the service, event or route the page could map", () => {
82
+ expect(readVerifyWarning('spans from service.name "auth-api" match no service in the catalog; name it under `services` to say which one it is')).toMatchObject({ kind: "service", name: "auth-api" });
83
+ expect(readVerifyWarning('publishes "auth.Ended", which matches no event of its own in the catalog; name it under `events`')).toMatchObject({ kind: "event", name: "auth.Ended" });
84
+ expect(readVerifyWarning('consumes "auth.Ended", which matches no event in the catalog; name it under `events`')).toMatchObject({ kind: "event", name: "auth.Ended" });
85
+ expect(readVerifyWarning("answers on GET /v1/health, which no interface it provides declares; the flow opens on the route rather than an operation")).toMatchObject({ kind: "route", name: "GET /v1/health" });
86
+ expect(readVerifyWarning("calls GET /v1/profiles/42 on profile, which no service in the catalog answers on; the hop is unresolved")).toMatchObject({ kind: "call", name: "GET /v1/profiles/42" });
87
+ expect(readVerifyWarning('publishes "a" on "x", but the catalog says it goes on "y"')).toMatchObject({ kind: "channel" });
88
+ expect(readVerifyWarning("something else")).toEqual({ kind: "other", message: "something else" });
89
+ });
90
+ });
91
+
92
+ describe("what a trial run said about the recording", () => {
93
+ const roots = [];
94
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
95
+
96
+ it("lists the flows the recording showed first, with what it raised and added", () => {
97
+ const snapshot = mkdtempSync(join(tmpdir(), "portolan-trace-trial-"));
98
+ roots.push(snapshot);
99
+ mkdirSync(join(snapshot, "examples/auth/portolan"), { recursive: true });
100
+ const recording = "telemetry/recordings/2026-09-12-login.jsonl";
101
+ const named = `examples/auth/${recording}`;
102
+ writeFileSync(join(snapshot, "examples/auth/portolan/observed.json"), JSON.stringify({
103
+ flows: [
104
+ {
105
+ id: "flow.auth-login", slug: "auth-login", name: "Login", owner: "auth", participants: [],
106
+ steps: [
107
+ { type: "step", id: "s1", status: "verified", seen: { traces: 1 } },
108
+ { type: "step", id: "s2", status: "declared" },
109
+ { type: "alt", id: "alt3", branches: [{ title: "blocked", steps: [{ type: "step", id: "seen1", status: "unresolved", seen: { traces: 1 } }] }] },
110
+ ],
111
+ examples: [{ id: `${recording}#t1`, recording: named, traceId: "t1", durationMs: 1, steps: [{ step: "s1", durationMs: 1 }, { step: "seen1", durationMs: 1 }] }],
112
+ },
113
+ {
114
+ id: "flow.observed-auth-get-v1-health", slug: "observed-auth-get-v1-health", name: "Observed: GET /v1/health", owner: "auth", participants: [],
115
+ steps: [{ type: "step", id: "s1", status: "verified", seen: { traces: 3 } }],
116
+ examples: [{ id: "telemetry/traces.jsonl#t9", recording: "examples/auth/telemetry/traces.jsonl", traceId: "t9", durationMs: 1, steps: [] }],
117
+ },
118
+ ],
119
+ }));
120
+ const step = { plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/recordings/*.jsonl"], out: "observed.json", services: { "auth-api": "auth.auth" } } };
121
+ const events = [
122
+ { type: "step-finished", phase: "extract", plugin: "go-domain", output: "examples/auth/portolan", status: "ok", warnings: ["ignored"] },
123
+ { type: "step-finished", phase: "verify", plugin: "otel", input: "examples/auth", output: "examples/auth/portolan", status: "warning", warnings: ['spans from service.name "risk" match no service in the catalog; name it under `services` to say which one it is'] },
124
+ ];
125
+
126
+ const summary = summarizeTraceTrial(snapshot, { projectId: "auth", root: "examples/auth", recording, step, stepAdded: true, stepChange: "widened", spans: 7 }, events);
127
+
128
+ expect(summary).toMatchObject({ project: "auth", recording, stepAdded: true, stepChange: "widened", status: "warning", spans: 7, mappings: { services: { "auth-api": "auth.auth" }, events: {}, routes: {} } });
129
+ expect(summary.warnings).toEqual([{ kind: "service", name: "risk", message: events[1].warnings[0] }]);
130
+ expect(summary.flows.map((flow) => flow.slug)).toEqual(["auth-login", "observed-auth-get-v1-health"]);
131
+ expect(summary.flows[0]).toMatchObject({ kind: "declared", inRecording: true, traces: 1, verified: 1, unresolved: 1, added: 1, shown: 2, steps: 3, examples: 1 });
132
+ expect(summary.flows[1]).toMatchObject({ kind: "observed", inRecording: false, traces: 0, verified: 1, added: 0 });
133
+ });
134
+
135
+ it("is a failed, empty summary when the verifier wrote nothing", () => {
136
+ const snapshot = mkdtempSync(join(tmpdir(), "portolan-trace-trial-"));
137
+ roots.push(snapshot);
138
+ const step = { plugin: "otel", in: "x", out: "x/portolan", options: { traces: [] } };
139
+ const summary = summarizeTraceTrial(snapshot, { projectId: "p", root: "x", recording: "telemetry/recordings/a.jsonl", step, stepAdded: false, spans: 1 }, []);
140
+ expect(summary).toMatchObject({ status: "failed", flows: [], warnings: [] });
141
+ });
142
+ });
@@ -0,0 +1,167 @@
1
+ import { Environment } from "@marcbachmann/cel-js";
2
+
3
+ const ENVIRONMENT = new Environment({
4
+ unlistedVariablesAreDyn: false,
5
+ limits: {
6
+ maxAstNodes: 256,
7
+ maxDepth: 32,
8
+ maxListElements: 32,
9
+ maxMapEntries: 32,
10
+ maxCallArguments: 8,
11
+ },
12
+ })
13
+ .registerVariable("plugin", "string")
14
+ .registerVariable("rule", "string")
15
+ .registerVariable("severity", "string")
16
+ .registerVariable("project", "string")
17
+ .registerVariable("phase", "string")
18
+ .registerVariable("ref", "string")
19
+ .registerVariable("message", "string")
20
+ .registerVariable("count", "int");
21
+ const COMPILED = new Map();
22
+
23
+ const RULES = [
24
+ rule("openapi.missing-operation-id", /\bno operationId\b/i, "warning", "Add stable operationId values to the OpenAPI operations."),
25
+ rule("schema.duplicate-declaration", /\bduplicate declaration\b/i, "warning", "Remove or reconcile duplicate schema declarations; Portolan currently uses the first."),
26
+ rule("analysis.typed-fallback", /\btyped call graph unavailable\b/i, "warning", "Run an extractor build with typed analysis support or inspect calls found by the syntax fallback."),
27
+ rule("catalog.unresolved-call", /\bcalls .+ which nothing in this catalog resolves\b/i, "warning", "Add or correct the provider contract, then regenerate to resolve the call."),
28
+ rule("catalog.unmapped-proto-peer", /\bmanifest names no peer for (?:that package|it)\b/i, "warning", "Map the protobuf package under peers, or declare it under externals."),
29
+ rule("flow.unresolved-step", /\bstep .+ is unresolved\b/i, "warning", "Declare the referenced endpoint, message, or store so this flow step can be joined."),
30
+ rule("flow.unknown-port", /\bis neither a domain port(?:,| nor) a use case\b/i, "warning", "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow."),
31
+ rule("flow.unreached-event", /\bno flow reaches this event\b/i, "warning", "Connect the event to its publisher flow or remove the stale event declaration."),
32
+ rule("flow.unknown-event", /\breacts to the message named .+ which no event .+ declares is called\b/i, "warning", "Declare the event name used by the handler or correct the handler mapping."),
33
+ rule("django.invalid-aggregate-root", /\baggregates names .+ and no model there is called that\b/i, "warning", "Choose an existing concrete model for the application's aggregates option."),
34
+ rule("django.unknown-http-verb", /\bmounted as an HTTP view, but no HTTP verb is declared\b/i, "warning", "Declare the accepted HTTP methods on the view or route."),
35
+ rule("river.missing-worker", /\bno registered Worker\b/i, "warning", "Register the River worker in this component or remove the unmatched insert."),
36
+ rule("watermill.unresolved-topic", /\bWatermill .+\btopic (?:generator could not be resolved|unresolved)\b/i, "warning", "Use a literal, constant, or configuration default for the Watermill topic."),
37
+ rule("messaging.unresolved-subject", /\bsubject of .+ could not be resolved\b/i, "warning", "Use a literal, constant, configuration default, or visible caller argument for the subject."),
38
+ rule("messaging.unresolved-queue", /\bqueue of .+ could not be resolved\b/i, "warning", "Use a literal, constant, configuration default, constructor argument, or visible caller argument for the queue."),
39
+ rule("terraform.unresolved-name", /\b(?:could not be resolved to a literal, a variable default, a local or a module argument|is named with \w+_prefix, so its name is decided at apply time|sets no \w+, so its name is decided at apply time)\b/i, "warning", "Name the resource with a literal, a variable default, a local or a module argument so the catalog can address it."),
40
+ rule("terraform.name-at-apply-time", /\bis `[^`]*`, with .+ decided at apply time\b/i, "info", "The literal part of the name is listed; the rest is filled when Terraform applies."),
41
+ rule("terraform.unresolved-reference", /\bcould not be followed to (?:a queue, a table or a stream|an? \w+(?: \w+)*) declared here\b/i, "warning", "Reference the resource by its Terraform address, or declare it in this module."),
42
+ rule("terraform.module-skipped", /\bmodule ".+" (?:comes from|calls) .+ and is not read\b/i, "info", "Vendor the module under the input root to have its resources read."),
43
+ rule("terraform.not-read", /\bis not read: .+ is not part of this reader yet\b/i, "info", "This AWS product is not modelled yet; the resource is listed so the gap is visible."),
44
+ rule("store.external-migrations", /\bmigrations are applied from .+ whose schema is not in this tree\b/i, "warning", "Vendor or expose the external migrations so their tables can be included in the store."),
45
+ rule("store.missing-foreign-table", /\bcolumn .+ references .+ which no migration here creates\b/i, "warning", "Include the referenced table migration or correct the foreign-key target."),
46
+ rule("schema.unresolved-type", /\bis not declared in the protos read here\b/i, "warning", "Include the imported protobuf declaration or map the type to an external schema."),
47
+ rule("source.offline-cache", /\bnot (?:fetched|read) \(offline\)/i, "info", "Regenerate with network access when the vendored copy must be refreshed."),
48
+ rule("source.unreachable", /\bnot (?:fetched|read) \((?!offline\))/i, "warning", "The far end could not be reached and the committed copy was used; check the server and the credential, then regenerate."),
49
+ rule("source.unpinned", /\bnot pinned\b/i, "warning", "Pin the source to an immutable commit or schema version."),
50
+ rule("source.parse-failed", /\bcould not (?:be read|parse|be parsed|be encoded)\b/i, "error", "Open the referenced source and fix the parse or read error."),
51
+ rule("extraction.no-match", /\b(?:no .+ (?:was|were) found|no .+ matched|declares no |no models in this application)\b/i, "info", "Confirm this capability is absent, or point the extractor at the source that declares it."),
52
+ ];
53
+
54
+ const FALLBACK_ACTION = "Inspect the referenced source and either fix the extraction gap or add a reviewed CEL policy with a reason.";
55
+
56
+ /**
57
+ * Classify, count and apply CEL policies to one step's raw warnings.
58
+ * Policy evaluation happens here, while the build still has its manifest;
59
+ * deployed sites receive decisions, not an expression runtime.
60
+ */
61
+ export function diagnoseWarnings({ plugin, warnings, policies = [], project = "", phase = "" }) {
62
+ const diagnostics = warnings.map((message) => classifyWarning(plugin, message));
63
+ const counts = new Map();
64
+ for (const diagnostic of diagnostics) {
65
+ const key = `${diagnostic.plugin}\0${diagnostic.rule}\0${diagnostic.severity}`;
66
+ counts.set(key, (counts.get(key) ?? 0) + 1);
67
+ }
68
+ const compiled = policies.map((policy) => ({ ...policy, expression: compilePolicy(policy.when) }));
69
+
70
+ return diagnostics.map((diagnostic) => {
71
+ const count = counts.get(`${diagnostic.plugin}\0${diagnostic.rule}\0${diagnostic.severity}`) ?? 1;
72
+ const context = {
73
+ plugin: diagnostic.plugin,
74
+ rule: diagnostic.rule,
75
+ severity: diagnostic.severity,
76
+ project,
77
+ phase,
78
+ ref: diagnostic.ref ?? "",
79
+ message: diagnostic.message,
80
+ count: BigInt(count),
81
+ };
82
+ const matched = compiled.find((policy) => policy.action === "suppress" && policy.expression(context) === true);
83
+ return {
84
+ ...diagnostic,
85
+ count,
86
+ project,
87
+ phase,
88
+ suppressed: Boolean(matched),
89
+ ...(matched ? { suppressionReason: matched.reason } : {}),
90
+ };
91
+ });
92
+ }
93
+
94
+ /** Validate CEL syntax, names and the boolean result type while reading the manifest. */
95
+ export function warningPolicyProblems(policies, path = "portolan.json") {
96
+ const problems = [];
97
+ for (const [index, policy] of (Array.isArray(policies) ? policies : []).entries()) {
98
+ if (!policy || typeof policy !== "object" || typeof policy.when !== "string") continue;
99
+ try {
100
+ compilePolicy(policy.when);
101
+ } catch (cause) {
102
+ problems.push(`${path} warningPolicies/${index}/when: ${cause instanceof Error ? cause.message : String(cause)}`);
103
+ }
104
+ }
105
+ return problems;
106
+ }
107
+
108
+ export function classifyWarning(plugin, message) {
109
+ const definition = RULES.find((candidate) => candidate.matches.test(message));
110
+ const ref = warningRef(message);
111
+ return {
112
+ plugin,
113
+ message,
114
+ rule: definition?.id ?? `plugin.${slug(plugin)}.other-${fingerprint(message)}`,
115
+ severity: definition?.severity ?? "warning",
116
+ action: definition?.action ?? FALLBACK_ACTION,
117
+ ...(ref ? { ref } : {}),
118
+ };
119
+ }
120
+
121
+ function compilePolicy(source) {
122
+ if (COMPILED.has(source)) return COMPILED.get(source);
123
+ let expression;
124
+ try {
125
+ expression = ENVIRONMENT.parse(source);
126
+ } catch (cause) {
127
+ throw new Error(`invalid CEL: ${cause instanceof Error ? firstLine(cause.message) : String(cause)}`);
128
+ }
129
+ const checked = expression.check();
130
+ if (!checked.valid) throw new Error(`invalid CEL: ${firstLine(checked.error?.message ?? "type check failed")}`);
131
+ if (checked.type !== "bool") throw new Error(`CEL expression must return bool, got ${checked.type}`);
132
+ COMPILED.set(source, expression);
133
+ return expression;
134
+ }
135
+
136
+ function rule(id, matches, severity, action) {
137
+ return { id, matches, severity, action };
138
+ }
139
+
140
+ function warningRef(message) {
141
+ const match = message.match(/^(.+?):\s+(?=[A-Za-z/])/);
142
+ return match?.[1]?.trim() || undefined;
143
+ }
144
+
145
+ function slug(value) {
146
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
147
+ }
148
+
149
+ function fingerprint(message) {
150
+ const ref = warningRef(message);
151
+ const body = (ref ? message.slice(message.indexOf(":", ref.length) + 1) : message)
152
+ .toLowerCase()
153
+ .replace(/`[^`]*`|"[^"]*"|'[^']*'/g, "<value>")
154
+ .replace(/\b\d+\b/g, "#")
155
+ .replace(/\s+/g, " ")
156
+ .trim();
157
+ let hash = 0x811c9dc5;
158
+ for (let index = 0; index < body.length; index += 1) {
159
+ hash ^= body.charCodeAt(index);
160
+ hash = Math.imul(hash, 0x01000193);
161
+ }
162
+ return (hash >>> 0).toString(36);
163
+ }
164
+
165
+ function firstLine(message) {
166
+ return String(message).split("\n", 1)[0];
167
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { classifyWarning, diagnoseWarnings, warningPolicyProblems } from "./warning-policy.mjs";
3
+
4
+ describe("warning CEL policies", () => {
5
+ const warnings = [
6
+ "aviacore-api: no operationId on 53 of 53 operations; listed by verb and path: GET /a, POST /b and 51 more",
7
+ "aviasupp-api: no operationId on 2 of 9 operations; listed by verb and path: POST /book, POST /cancel",
8
+ ];
9
+
10
+ it("evaluates typed CEL over the diagnostic and its repetition count", () => {
11
+ const diagnostics = diagnoseWarnings({
12
+ plugin: "openapi",
13
+ warnings,
14
+ project: "aviacore",
15
+ phase: "extract",
16
+ policies: [{
17
+ when: "plugin == 'openapi' && rule == 'openapi.missing-operation-id' && project == 'aviacore' && count >= 2",
18
+ action: "suppress",
19
+ reason: "Owned upstream.",
20
+ }],
21
+ });
22
+
23
+ expect(diagnostics).toHaveLength(2);
24
+ expect(diagnostics[0]).toMatchObject({
25
+ rule: "openapi.missing-operation-id",
26
+ count: 2,
27
+ project: "aviacore",
28
+ phase: "extract",
29
+ suppressed: true,
30
+ suppressionReason: "Owned upstream.",
31
+ });
32
+ });
33
+
34
+ it("leaves non-matching diagnostics active", () => {
35
+ const [diagnostic] = diagnoseWarnings({
36
+ plugin: "openapi",
37
+ warnings: warnings.slice(0, 1),
38
+ project: "aviasupp",
39
+ phase: "extract",
40
+ policies: [{ when: "project == 'aviacore'", action: "suppress", reason: "Only core." }],
41
+ });
42
+ expect(diagnostic?.suppressed).toBe(false);
43
+ });
44
+
45
+ it("reports unknown variables, syntax errors and non-boolean expressions", () => {
46
+ expect(warningPolicyProblems([{ when: "unknown == 1" }])).toEqual([
47
+ expect.stringContaining("Unknown variable: unknown"),
48
+ ]);
49
+ expect(warningPolicyProblems([{ when: "plugin ==" }])).toEqual([
50
+ expect.stringContaining("invalid CEL"),
51
+ ]);
52
+ expect(warningPolicyProblems([{ when: "plugin" }])).toEqual([
53
+ expect.stringContaining("must return bool"),
54
+ ]);
55
+ });
56
+ });
57
+
58
+ describe("warning classification", () => {
59
+ // Each message is worded the way the plugin that emits it words it today
60
+ // (grep the plugin for the phrase before editing a rule), so a policy that
61
+ // names the rule keeps matching during generation.
62
+ const emitted = [
63
+ ["openapi", "aviacore-api: no operationId on 53 of 53 operations; listed by verb and path: GET /a, POST /b and 51 more", "openapi.missing-operation-id"],
64
+ ["go-domain", "calls ledger.v1 and the manifest names no peer for that package; add it under `peers` to say which service answers, or under `externals` when the far end is outside the estate, until then the calls are unresolved", "catalog.unmapped-proto-peer"],
65
+ ["java-domain", "calls ledger.v1 and the manifest names no peer for it; add it under `peers` to say which service answers, or under `externals` when the far end is outside the estate, until then the calls are unresolved", "catalog.unmapped-proto-peer"],
66
+ ["go-domain", "internal/app: port `clock Clock` is neither a domain port nor a use case; its calls are left out of the flow", "flow.unknown-port"],
67
+ ["rust-domain", "src/app.rs: port `clock: Clock` is neither a domain port, a use case nor a client; its calls are left out of the flow", "flow.unknown-port"],
68
+ ["watermill", "internal/bus/router.go:12:3: Watermill handler orders is registered on topic `cfg.Topic`, which this reader cannot resolve to a literal, a constant, a config default or a caller's argument; the handler is kept with its topic unresolved", "watermill.unresolved-topic"],
69
+ ["watermill", "internal/bus/cqrs.go:40:5: Watermill CQRS handler topic generator could not be resolved", "watermill.unresolved-topic"],
70
+ ["go-nats", "internal/pub.go:8:2: subject of Publish could not be resolved to a literal, a constant, a config default or a caller's argument", "messaging.unresolved-subject"],
71
+ ["go-sqs", "internal/relay.go:15:2: queue of SendMessage could not be resolved to a literal, a constant, a config default, a constructor's argument or a caller's argument", "messaging.unresolved-queue"],
72
+ ["terraform", "deploy/queues.tf:12: name of aws_sqs_queue.opaque could not be resolved to a literal, a variable default, a local or a module argument", "terraform.unresolved-name"],
73
+ ["terraform", "deploy/queues.tf:40: aws_sqs_queue.generated is named with name_prefix, so its name is decided at apply time and is not read", "terraform.unresolved-name"],
74
+ ["terraform", "deploy/lambda.tf:52: aws_lambda_function.unnamed sets no function_name, so its name is decided at apply time and is not read", "terraform.unresolved-name"],
75
+ ["terraform", "deploy/data.tf:80: name of aws_sqs_queue.per_account is `fulfillment-events-{account_id}`, with {account_id} decided at apply time", "terraform.name-at-apply-time"],
76
+ ["terraform", "deploy/lambda.tf:30: event_source_arn of aws_lambda_event_source_mapping.in could not be followed to a queue, a table or a stream declared here", "terraform.unresolved-reference"],
77
+ ["terraform", "deploy/lambda.tf:30: function_name of aws_lambda_event_source_mapping.in could not be followed to an aws_lambda_function declared here", "terraform.unresolved-reference"],
78
+ ["terraform", "deploy/main.tf:36: module \"vpc\" comes from terraform-aws-modules/vpc/aws, which is not in this tree, and is not read", "terraform.module-skipped"],
79
+ ["terraform", "deploy/data.tf:69: aws_kinesis_stream.clicks is not read: Kinesis is not part of this reader yet", "terraform.not-read"],
80
+ ["git", "github.com/acme/ledger: not fetched (offline); the copy committed in this repository is used unchanged", "source.offline-cache"],
81
+ ["markdown", "flow.checkout step \"pay\" is unresolved: POST /pay", "flow.unresolved-step"],
82
+ ["rust-domain", "Confirm: src/policy.rs: Confirm.handle reacts to the message named \"payment.authorized\", which no event this repository declares is called; the step is unresolved", "flow.unknown-event"],
83
+ ];
84
+
85
+ it.each(emitted)("classifies what %s emits to a stable rule", (plugin, message, rule) => {
86
+ expect(classifyWarning(plugin, message).rule).toBe(rule);
87
+ });
88
+
89
+ it("no longer carries a rule for a warning no plugin emits", () => {
90
+ const message = "billing/records: no model called Records, and 2 models to choose from: name the root in the aggregates option";
91
+ expect(classifyWarning("django-domain", message).rule).toMatch(/^plugin\.django-domain\.other-/);
92
+ });
93
+ });
@@ -10,6 +10,9 @@ describe("crumbsFor", () => {
10
10
  expect(crumbsFor("/language")).toEqual([
11
11
  { label: "language", to: "/language" },
12
12
  ]);
13
+ expect(crumbsFor("/plugins")).toEqual([
14
+ { label: "plugins", to: "/plugins" },
15
+ ]);
13
16
  expect(crumbsFor("/problems")).toEqual([
14
17
  { label: "problems", to: "/problems" },
15
18
  ]);
@@ -85,6 +85,9 @@ export function crumbsFor(pathname: string): Crumb[] {
85
85
  if (parts[0] === "problems")
86
86
  return [{ label: "problems", to: paths.problems() }];
87
87
 
88
+ if (parts[0] === "plugins")
89
+ return [{ label: "plugins", to: paths.plugins() }];
90
+
88
91
  if (parts[0] === "registry") {
89
92
  const crumbs: Crumb[] = [{ label: "registry", to: paths.registry() }];
90
93
  const slug = parts[1];
@@ -18,6 +18,7 @@ import { FlowDetail } from "../pages/FlowDetail";
18
18
  import { FlowIndex } from "../pages/FlowIndex";
19
19
  import { AdrIndex } from "../pages/AdrIndex";
20
20
  import { Language } from "../pages/Language";
21
+ import { PluginIndex } from "../pages/PluginIndex";
21
22
  import { AdrDetail } from "../pages/AdrDetail";
22
23
  import { Overview } from "../pages/Overview";
23
24
  import { ContextMap } from "../pages/ContextMap";
@@ -119,6 +120,7 @@ function AppRoutes({
119
120
  />
120
121
  <Route path="/adrs" element={<AdrIndex />} />
121
122
  <Route path="/language" element={<Language />} />
123
+ <Route path="/plugins" element={<PluginIndex />} />
122
124
  <Route path="/adrs/:adr" element={<AdrDetail />} />
123
125
  <Route path="/problems" element={<Problems />} />
124
126
  <Route path="/settings/*" element={<Settings />} />
@@ -878,15 +878,15 @@ function AggregateNode({
878
878
  depth={2}
879
879
  open={aopen}
880
880
  onToggle={() => toggle(akey, false)}
881
- label={`aggregate ${aggregate.name}`}
881
+ label={`${aggregate.kind === "model-group" ? "model group" : "aggregate"} ${aggregate.name}`}
882
882
  selId={aggregate.id}
883
883
  under={
884
884
  <div
885
885
  className="mono truncate pr-2 text-muted"
886
886
  style={{ paddingLeft: indent(3), fontSize: 11 }}
887
- title={`aggregate root: ${aggregate.root}`}
887
+ title={aggregate.kind === "model-group" ? "Model group; aggregate boundary not specified" : `aggregate root: ${aggregate.root}`}
888
888
  >
889
- root: {aggregate.root}
889
+ {aggregate.kind === "model-group" ? "model group" : `root: ${aggregate.root}`}
890
890
  </div>
891
891
  }
892
892
  right={