@shortlink-org/portolan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, 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 { newestMtime, pluginsFresh } from "./plugins-fresh.mjs";
7
+
8
+ const roots = [];
9
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
10
+
11
+ function at(path, seconds) {
12
+ utimesSync(path, seconds, seconds);
13
+ }
14
+
15
+ /** A workspace with a built wasm and Java classes, all stamped at `built`, over sources stamped at `source`. */
16
+ function workspace({ source, built }) {
17
+ const root = mkdtempSync(join(tmpdir(), "portolan-fresh-"));
18
+ roots.push(root);
19
+ for (const dir of ["plugins/verify-otel", "plugins/extract-java/src/org", "plugins/extract-java/build/org", "catalog", "plugin"]) mkdirSync(join(root, dir), { recursive: true });
20
+ const files = {
21
+ "go.mod": source, "plugins/verify-otel/verify.go": source, "catalog/model.go": source, "plugin/protocol.go": source,
22
+ "plugins/extract-java/src/org/Main.java": source,
23
+ "plugins/portolan-go.wasm": built, "plugins/extract-java/build/org/Main.class": built,
24
+ };
25
+ for (const [name, stamp] of Object.entries(files)) {
26
+ writeFileSync(join(root, name), "x");
27
+ at(join(root, name), stamp);
28
+ }
29
+ return root;
30
+ }
31
+
32
+ describe("whether the built plugins are newer than their sources", () => {
33
+ it("is yes when every artefact is at least as new as every source", () => {
34
+ expect(pluginsFresh(workspace({ source: 1000, built: 2000 }))).toBe(true);
35
+ expect(pluginsFresh(workspace({ source: 2000, built: 2000 }))).toBe(true);
36
+ });
37
+
38
+ it("is no when a Go source, a Java source, or go.mod is newer, or an artefact is missing", () => {
39
+ const go = workspace({ source: 1000, built: 2000 });
40
+ at(join(go, "catalog/model.go"), 3000);
41
+ expect(pluginsFresh(go)).toBe(false);
42
+
43
+ const java = workspace({ source: 1000, built: 2000 });
44
+ at(join(java, "plugins/extract-java/src/org/Main.java"), 3000);
45
+ expect(pluginsFresh(java)).toBe(false);
46
+
47
+ const mod = workspace({ source: 1000, built: 2000 });
48
+ at(join(mod, "go.mod"), 3000);
49
+ expect(pluginsFresh(mod)).toBe(false);
50
+
51
+ const missing = workspace({ source: 1000, built: 2000 });
52
+ rmSync(join(missing, "plugins/portolan-go.wasm"));
53
+ expect(pluginsFresh(missing)).toBe(false);
54
+ });
55
+
56
+ it("leaves a plugin's own build output and test data out of the sources", () => {
57
+ const root = workspace({ source: 1000, built: 2000 });
58
+ mkdirSync(join(root, "plugins/extract-rust/target"), { recursive: true });
59
+ writeFileSync(join(root, "plugins/extract-rust/target/out.go"), "x");
60
+ at(join(root, "plugins/extract-rust/target/out.go"), 5000);
61
+ expect(pluginsFresh(root)).toBe(true);
62
+ expect(newestMtime(join(root, "plugins"), (path) => path.endsWith(".go"))).toBe(1000 * 1000);
63
+ });
64
+ });
@@ -27,8 +27,9 @@ if (!plugin?.process) {
27
27
  const command = plugin.process.command;
28
28
  const args = [...(plugin.process.args ?? [])];
29
29
 
30
- if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"))) {
31
- run(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"), []);
30
+ const prebuilt = command === "cargo" ? prebuiltCargoBinary(args) : "";
31
+ if (prebuilt) {
32
+ run(prebuilt, []);
32
33
  } else if (command === "go") {
33
34
  buildAndRunGo(args);
34
35
  } else {
@@ -49,7 +50,14 @@ if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust
49
50
  // describe/extract calls cheap; rebuilding also prevents a stale sidecar after
50
51
  // an npm upgrade with the same workspace cache.
51
52
  function buildAndRunGo(argv) {
52
- if (argv[0] !== "run" || typeof argv[1] !== "string" || !argv[1] || argv[1].startsWith("-")) {
53
+ // `go run [build flags] <package> [args]`: the build flags (-mod=mod, -tags)
54
+ // belong to the build, the package is what is built, the rest is the
55
+ // sidecar's own command line.
56
+ const flags = [];
57
+ let at = 1;
58
+ while (argv[0] === "run" && typeof argv[at] === "string" && argv[at].startsWith("-")) flags.push(argv[at++]);
59
+ const pkg = argv[at];
60
+ if (argv[0] !== "run" || typeof pkg !== "string" || !pkg) {
53
61
  console.error(`portolan: built-in ${name} has an unsupported Go command`);
54
62
  process.exitCode = 2;
55
63
  return;
@@ -57,7 +65,7 @@ function buildAndRunGo(argv) {
57
65
  const binDir = resolve(workspace, ".portolan", "bin", "go");
58
66
  const executable = resolve(binDir, process.platform === "win32" ? `${name}.exe` : name);
59
67
  mkdirSync(binDir, { recursive: true });
60
- const built = spawnSync("go", ["build", "-mod=readonly", "-o", executable, argv[1]], {
68
+ const built = spawnSync("go", ["build", "-mod=mod", ...flags.filter((flag) => flag !== "-mod=mod"), "-o", executable, pkg], {
61
69
  cwd: installRoot,
62
70
  env: { ...process.env, GOWORK: "off" },
63
71
  stdio: "inherit",
@@ -71,12 +79,25 @@ function buildAndRunGo(argv) {
71
79
  process.exitCode = built.status ?? 1;
72
80
  return;
73
81
  }
74
- run(executable, argv.slice(2));
82
+ run(executable, argv.slice(at + 1));
83
+ }
84
+
85
+ // A Rust plugin already built in release mode under its own crate directory,
86
+ // `plugins/extract-rust/target/release/portolan-extract-rust`, is run as it
87
+ // is; the crate is named after its directory, which is what the manifest
88
+ // path names.
89
+ function prebuiltCargoBinary(argv) {
90
+ const manifest = argv[argv.indexOf("--manifest-path") + 1];
91
+ if (!manifest || argv.indexOf("--manifest-path") < 0) return "";
92
+ const crate = dirname(manifest).split("/").pop();
93
+ const binary = resolve(installRoot, dirname(manifest), "target", "release", `portolan-${crate}`);
94
+ return existsSync(binary) ? binary : "";
75
95
  }
76
96
 
77
97
  function resolveArgument(executable, index) {
78
98
  if (["node", "python", "python3"].includes(executable)) return index === 0;
79
99
  if (executable === "java") return index === 1;
100
+ if (executable === "dotnet") return index === 0;
80
101
  if (executable === "cargo") return index === 3;
81
102
  return false;
82
103
  }
@@ -1,8 +1,9 @@
1
1
  // Composes the JSON Schema for portolan.json out of what the plugins say about
2
2
  // themselves.
3
3
  //
4
- // node scripts/schema.mjs write schema/portolan.schema.json
5
- // node scripts/schema.mjs --check fail if the schema on disk is not what
4
+ // node scripts/schema.mjs write schema/portolan.schema.json and
5
+ // src/lib/plugin-index.json
6
+ // node scripts/schema.mjs --check fail if either file on disk is not what
6
7
  // the plugins describe now
7
8
  //
8
9
  // The manifest tells each plugin the things its source cannot carry - which
@@ -19,8 +20,14 @@
19
20
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
21
 
21
22
  import { describePlugin } from "./plugin-host.mjs";
23
+ import { RULE_ID, SEVERITIES, SUBJECT_NAMES, SUBJECTS } from "../src/lib/problem-rules-cel.mjs";
24
+
25
+ // The built-in problem rules, so the schema can say which ids are switches
26
+ // and which must carry a whole rule.
27
+ const BUILTIN_RULES = JSON.parse(readFileSync("rules/builtin.json", "utf8"));
22
28
 
23
29
  const OUT = "schema/portolan.schema.json";
30
+ const INDEX = "src/lib/plugin-index.json";
24
31
 
25
32
  const check = process.argv.includes("--check");
26
33
  // Bootstrap exception: this command produces the schema that loadManifest
@@ -51,25 +58,65 @@ for (const plugin of manifest.plugins ?? []) {
51
58
  }
52
59
 
53
60
  const composed = `${JSON.stringify(compose(), null, 2)}\n`;
61
+ const index = `${JSON.stringify(pluginIndex(), null, 2)}\n`;
62
+
63
+ let stale = false;
64
+ for (const [path, contents, directory] of [
65
+ [OUT, composed, "schema"],
66
+ [INDEX, index, "src/lib"],
67
+ ]) {
68
+ let current = null;
69
+ try {
70
+ current = readFileSync(path, "utf8");
71
+ } catch {
72
+ // Absent, which is the same as out of date.
73
+ }
54
74
 
55
- let current = null;
56
- try {
57
- current = readFileSync(OUT, "utf8");
58
- } catch {
59
- // Absent, which is the same as out of date.
75
+ if (current === contents) {
76
+ console.log(`${path}: up to date`);
77
+ } else if (check) {
78
+ console.error(
79
+ `${path} is ${current === null ? "missing" : "out of date"}. Run \`npm run schema\`.`,
80
+ );
81
+ stale = true;
82
+ } else {
83
+ mkdirSync(directory, { recursive: true });
84
+ writeFileSync(path, contents);
85
+ console.log(`${path}: written from ${described.size} plugin${described.size === 1 ? "" : "s"}`);
86
+ }
60
87
  }
88
+ if (stale) process.exit(1);
61
89
 
62
- if (current === composed) {
63
- console.log(`${OUT}: up to date`);
64
- } else if (check) {
65
- console.error(
66
- `${OUT} is ${current === null ? "missing" : "out of date"}. Run \`npm run schema\`.`,
67
- );
68
- process.exit(1);
69
- } else {
70
- mkdirSync("schema", { recursive: true });
71
- writeFileSync(OUT, composed);
72
- console.log(`${OUT}: written from ${described.size} plugin${described.size === 1 ? "" : "s"}`);
90
+ /**
91
+ * The plugin index: every plugin the package ships, as it describes itself,
92
+ * with how the manifest runs it. The site's /plugins page and the landing's
93
+ * list of inputs are rendered from this, so a plugin added to portolan.json
94
+ * appears there without anyone writing it down twice. Kept under src/ rather
95
+ * than beside the schema because the staged site copies src/ and nothing else.
96
+ */
97
+ function pluginIndex() {
98
+ const out = [];
99
+ for (const plugin of manifest.plugins ?? []) {
100
+ const descriptor = described.get(plugin.name);
101
+ if (!descriptor) continue;
102
+ out.push({
103
+ name: plugin.name,
104
+ plugin: descriptor.name,
105
+ summary: descriptor.summary,
106
+ category: descriptor.category,
107
+ phases: descriptor.phases,
108
+ ...(descriptor.needs?.length ? { needs: descriptor.needs } : {}),
109
+ runtime: plugin.wasm ? "wasm" : plugin.host ? "host" : "process",
110
+ // The toolchain a process plugin asks the build for; the sandboxed
111
+ // module and the host's own code ask for nothing.
112
+ ...(plugin.process ? { toolchain: plugin.process.command } : {}),
113
+ source: plugin.host
114
+ ? `scripts/host-plugins/${plugin.host}.mjs`
115
+ : `plugins/${descriptor.name}`,
116
+ options: descriptor.options ?? {},
117
+ });
118
+ }
119
+ return out;
73
120
  }
74
121
 
75
122
  function compose() {
@@ -123,6 +170,13 @@ function compose() {
123
170
  description:
124
171
  "CEL policies for reviewed extraction limitations. Expressions are type-checked when the manifest is read and suppression always requires a reason.",
125
172
  },
173
+ problemRules: {
174
+ type: "array",
175
+ maxItems: 200,
176
+ items: { $ref: "#/$defs/problemRule" },
177
+ description:
178
+ "The rules the Problems page applies. An entry naming a built-in rule (rules/builtin.json) switches it off or re-grades it, with a reason; any other id is a rule of its own, written in CEL over one subject. Expressions are type-checked when the manifest is read and run in the page over the merged catalog.",
179
+ },
126
180
  plugins: {
127
181
  type: "array",
128
182
  items: { $ref: "#/$defs/plugin" },
@@ -146,6 +200,7 @@ function compose() {
146
200
  },
147
201
  },
148
202
  $defs: {
203
+ problemRule: problemRuleSchema(),
149
204
  warningPolicy: {
150
205
  type: "object",
151
206
  additionalProperties: false,
@@ -273,7 +328,7 @@ function compose() {
273
328
  description: "What the steps below call this plugin. It need not be the plugin's own name.",
274
329
  },
275
330
  host: {
276
- enum: ["fetch-git", "fetch-bsr", "fetch-csr"],
331
+ enum: ["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"],
277
332
  description:
278
333
  "A plugin the host runs inside its own process, because it needs a socket or a git binary (portolan.0008). Only the ones shipped with Portolan can be named.",
279
334
  },
@@ -400,3 +455,67 @@ function fail(message) {
400
455
  console.error(`portolan schema: ${message}`);
401
456
  process.exit(1);
402
457
  }
458
+
459
+ /**
460
+ * One entry of `problemRules`. Two shapes under one id field: a built-in id
461
+ * may only be switched, re-graded and explained; any other id is a whole
462
+ * rule, over one subject, with the expressions the page runs.
463
+ */
464
+ function problemRuleSchema() {
465
+ const builtinIds = BUILTIN_RULES.map((rule) => rule.id);
466
+ const subjectFields = SUBJECT_NAMES.map(
467
+ (name) => `${name}: ${Object.keys(SUBJECTS[name].schema).join(", ")}`,
468
+ ).join("; ");
469
+ const expression = (returns, what) => ({
470
+ type: "string",
471
+ minLength: 1,
472
+ maxLength: 2000,
473
+ description: `${what} CEL returning ${returns}, over the subject named by \`over\` and \`estate\` (services, contexts, stores, channels, externals). Fields by subject: ${subjectFields}.`,
474
+ });
475
+ return {
476
+ type: "object",
477
+ additionalProperties: false,
478
+ required: ["id"],
479
+ properties: {
480
+ id: {
481
+ type: "string",
482
+ pattern: RULE_ID.source,
483
+ description: `The rule. One of the built-in ids - ${builtinIds.join(", ")} - to switch or re-grade that rule; any other id declares a rule of its own.`,
484
+ },
485
+ enabled: {
486
+ type: "boolean",
487
+ default: true,
488
+ description: "Whether the rule produces rows. A disabled rule needs a reason and stays listed on the Settings page.",
489
+ },
490
+ severity: {
491
+ enum: SEVERITIES,
492
+ description: "How wrong a row of this rule is. For a built-in rule this replaces the severity its passport was written with.",
493
+ },
494
+ reason: {
495
+ type: "string",
496
+ minLength: 1,
497
+ description: "Why the rule is off or re-graded, or, for a rule of your own, why the estate holds it.",
498
+ },
499
+ over: {
500
+ enum: SUBJECT_NAMES,
501
+ description: "What one row of the rule is about, and what `when` and `message` read.",
502
+ },
503
+ when: expression("bool", "The condition: a row exists for every subject where it holds."),
504
+ message: expression("string", "The note on the row."),
505
+ peer: expression("string", "The row's far end, when it has one; a service, event or table id links to its page."),
506
+ title: { type: "string", minLength: 1, description: "The rule's name on the Settings page." },
507
+ note: { type: "string", minLength: 1, description: "The short words on every row of the rule; the title when absent." },
508
+ description: { type: "string", description: "What the rule checks and why it matters." },
509
+ action: { type: "string", description: "What a reader does about a row." },
510
+ },
511
+ if: { properties: { id: { enum: builtinIds } } },
512
+ then: {
513
+ not: {
514
+ anyOf: ["over", "when", "message", "peer", "title", "note", "description", "action"].map((key) => ({
515
+ required: [key],
516
+ })),
517
+ },
518
+ },
519
+ else: { required: ["over", "when", "message", "title"] },
520
+ };
521
+ }
@@ -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
+ });