@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
package/scripts/README.md CHANGED
@@ -37,7 +37,9 @@ and validates the union. Nothing here draws anything.
37
37
  - Read a plugin's stdout for anything but the one message, or its stderr for
38
38
  anything but notes; a warning is kept beside the step in the build report.
39
39
  - Draw the C4 model: `gen-likec4.mjs` writes `likec4/` from the same merged
40
- catalog, and the site draws.
40
+ catalog, and the site draws. It runs as the last step of `gen.mjs` -
41
+ written, or in check mode held to the catalog like every generated page -
42
+ and on its own before `dev`.
41
43
 
42
44
  ## Files
43
45
 
@@ -79,11 +79,23 @@ function repositoryName(remote, workspace) {
79
79
  return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
80
80
  }
81
81
 
82
+ // One ref listing answers three questions (where origin/HEAD points, whether
83
+ // main or master exists) instead of one git process per question: a preview
84
+ // spawns git while the test suite and the site build are already competing
85
+ // for the machine, and every spawn costs seconds there.
82
86
  function defaultBranch(workspace) {
83
- const remoteHead = git(workspace, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
84
- if (remoteHead.startsWith("origin/")) return remoteHead.slice("origin/".length);
87
+ const refs = new Map();
88
+ for (const line of git(workspace, [
89
+ "for-each-ref", "--format=%(refname)%09%(symref)",
90
+ "refs/remotes/origin/HEAD", "refs/heads/main", "refs/heads/master",
91
+ ]).split("\n")) {
92
+ const [name, symref = ""] = line.split("\t");
93
+ if (name) refs.set(name, symref);
94
+ }
95
+ const remoteHead = refs.get("refs/remotes/origin/HEAD") ?? "";
96
+ if (remoteHead.startsWith("refs/remotes/origin/")) return remoteHead.slice("refs/remotes/origin/".length);
85
97
  for (const candidate of ["main", "master"]) {
86
- if (git(workspace, ["rev-parse", "--verify", `refs/heads/${candidate}`])) return candidate;
98
+ if (refs.has(`refs/heads/${candidate}`)) return candidate;
87
99
  }
88
100
  return git(workspace, ["branch", "--show-current"]) || "main";
89
101
  }
@@ -316,8 +328,29 @@ function mergeGitlab(existing, features) {
316
328
  return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
317
329
  }
318
330
 
331
+ // A file that is entirely new or entirely gone needs no diff algorithm: every
332
+ // line is added or removed. Writing that hunk here saves a git process per
333
+ // file, and a GitHub preset is mostly such files.
334
+ function wholeFileDiff(path, content, sign) {
335
+ const body = content.endsWith("\n") ? content.slice(0, -1) : content;
336
+ const lines = body.split("\n");
337
+ const count = lines.length === 1 ? "1" : `1,${lines.length}`;
338
+ const range = sign === "+" ? `-0,0 +${count}` : `-${count} +0,0`;
339
+ return [
340
+ `diff --git a/${path} b/${path}`,
341
+ `--- a/${path}`,
342
+ `+++ b/${path}`,
343
+ `@@ ${range} @@`,
344
+ ...lines.map((line) => `${sign}${line}`),
345
+ ...(content.endsWith("\n") ? [] : ["\"]),
346
+ "",
347
+ ].join("\n");
348
+ }
349
+
319
350
  function textDiff(path, before, after) {
320
351
  if (before === after) return "";
352
+ if (before === "") return wholeFileDiff(path, after, "+");
353
+ if (after === "") return wholeFileDiff(path, before, "-");
321
354
  const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
322
355
  const left = join(holder, "before");
323
356
  const right = join(holder, "after");
@@ -7,16 +7,24 @@
7
7
  //
8
8
  // node scripts/gen-likec4.mjs
9
9
 
10
- import { writeFileSync, mkdirSync } from "node:fs";
10
+ import { writeFileSync, mkdirSync, realpathSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
11
13
 
12
14
  import { loadCatalog } from "./catalog-sources.mjs";
13
15
  import reserved from "../src/likec4/reserved.json" with { type: "json" };
14
16
  import { catalogProfiles } from "../src/catalog-profile.ts";
17
+ import { allDeployments, deploys, environmentOf } from "../src/catalog-model.ts";
15
18
 
16
19
  // Every source, not one file: a service that publishes its own facts gets a
17
20
  // C4 view like any other, and generating from a single file would leave it out
18
21
  // of the pictures while the rest of the app knows about it.
19
- const { catalog, manifest } = await loadCatalog();
22
+ //
23
+ // The sources come back as files rather than being written here, so that
24
+ // `gen` can settle them the way it settles every generated page - written,
25
+ // or in check mode compared and reported as drift - and `likec4:gen` can
26
+ // still write them on its own before the dev server starts.
27
+ export async function likec4Sources({ catalog, manifest }) {
20
28
  const profiles = catalogProfiles(manifest);
21
29
 
22
30
  // --- ids (mirrors src/likec4/ids.ts; kept in step by src/likec4/ids.test.ts) ---
@@ -36,6 +44,8 @@ const flowCrossViewId = (flow) => `${flowViewId(flow)}_cross`;
36
44
  const contextViewId = (c) => `ctx_${safeId(c.id)}`;
37
45
  const serviceViewId = (s) => `svc_${safeId(s.id)}`;
38
46
  const serviceInsideViewId = (s) => `${serviceViewId(s)}_inside`;
47
+ const deploymentViewId = (environment) => `deploy_${safeId(environment)}`;
48
+ const serviceDeployViewId = (s) => `deploy_svc_${safeId(s.id)}`;
39
49
  const LANDSCAPE_VIEW = "landscape";
40
50
  const CONTAINERS_VIEW = "containers";
41
51
  const profileLandscapeViewId = (profile) =>
@@ -248,6 +258,18 @@ spec.push(" element unknown {");
248
258
  spec.push(" style { shape rectangle color unresolved border dashed }");
249
259
  spec.push(" }");
250
260
  spec.push("");
261
+ // Where a service runs (portolan.0012): three nested places, drawn as
262
+ // frames around the instances they hold, from the deployer's snapshot.
263
+ spec.push(" deploymentNode environment {");
264
+ spec.push(" style { shape rectangle opacity 5% border dashed }");
265
+ spec.push(" }");
266
+ spec.push(" deploymentNode cluster {");
267
+ spec.push(" style { shape rectangle opacity 10% border dashed color muted }");
268
+ spec.push(" }");
269
+ spec.push(" deploymentNode namespace {");
270
+ spec.push(" style { shape rectangle opacity 15% color muted }");
271
+ spec.push(" }");
272
+ spec.push("");
251
273
  for (const kind of RELATION_KINDS) spec.push(` relationship ${kind}`);
252
274
  spec.push("}");
253
275
 
@@ -1050,20 +1072,131 @@ for (const flow of catalog.flows) {
1050
1072
  views.push(" }");
1051
1073
  views.push("");
1052
1074
  }
1075
+ // ---------------------------------------------------------------------------
1076
+ // deployment: where the services run (portolan.0012)
1077
+ // ---------------------------------------------------------------------------
1078
+ // One tree per environment, the cluster and the namespace as frames inside
1079
+ // it, and an instance of the service in the namespace it stands in. The
1080
+ // relations are the model's own: LikeC4 draws between two instances what the
1081
+ // model declares between their elements, so an environment's picture shows
1082
+ // the calls both ends of which are deployed there, and nothing a reader
1083
+ // would have to fold by hand. One view per environment, and one per service
1084
+ // that runs somewhere: its instances with the frames around them.
1085
+ const deployment = [];
1086
+ const deployTree = new Map(); // environment -> cluster -> namespace -> Map(service id -> {service, deployment})
1087
+ const placesOfService = new Map(); // service id -> [fqn of instance]
1088
+ const framesOfEnvironment = new Map(); // environment -> Set(fqn of every node under it)
1089
+ for (const placed of allDeployments(catalog)) {
1090
+ for (const service of allServices) {
1091
+ if (!deploys(placed, service)) continue;
1092
+ const env = environmentOf(placed);
1093
+ const clusters = deployTree.get(env) ?? new Map();
1094
+ deployTree.set(env, clusters);
1095
+ const namespaces = clusters.get(placed.cluster) ?? new Map();
1096
+ clusters.set(placed.cluster, namespaces);
1097
+ const instances = namespaces.get(placed.namespace) ?? new Map();
1098
+ namespaces.set(placed.namespace, instances);
1099
+ // Two Applications of one service in one place is one instance: the
1100
+ // picture is about where it runs, and it runs there once.
1101
+ if (!instances.has(service.id)) instances.set(service.id, { service, deployment: placed });
1102
+ }
1103
+ }
1104
+ const environments = [...deployTree.keys()].sort();
1105
+ const sorted = (map) => [...map.keys()].sort();
1106
+ if (environments.length > 0) {
1107
+ deployment.push("deployment {");
1108
+ for (const env of environments) {
1109
+ const envId = safeId(env);
1110
+ deployment.push(` environment ${envId} ${q(env)} {`);
1111
+ const clusters = deployTree.get(env);
1112
+ for (const cluster of sorted(clusters)) {
1113
+ const path = [envId];
1114
+ let indent = " ";
1115
+ if (cluster) {
1116
+ path.push(safeId(cluster));
1117
+ deployment.push(`${indent}cluster ${safeId(cluster)} ${q(cluster)} {`);
1118
+ indent += " ";
1119
+ }
1120
+ const namespaces = clusters.get(cluster);
1121
+ for (const namespace of sorted(namespaces)) {
1122
+ const nsPath = [...path];
1123
+ let nsIndent = indent;
1124
+ if (namespace) {
1125
+ nsPath.push(safeId(namespace));
1126
+ deployment.push(`${nsIndent}namespace ${safeId(namespace)} ${q(namespace)} {`);
1127
+ nsIndent += " ";
1128
+ }
1129
+ const instances = namespaces.get(namespace);
1130
+ for (const serviceId of sorted(instances)) {
1131
+ const { service, deployment: placed } = instances.get(serviceId);
1132
+ const instanceId = safeId(service.id);
1133
+ const revision = /^[0-9a-f]{40}$/.test(placed.revision) ? placed.revision.slice(0, 7) : placed.revision;
1134
+ const about = [placed.name, revision ? `at ${revision}` : ""].filter(Boolean).join(" ");
1135
+ deployment.push(`${nsIndent}${instanceId} = instanceOf ${fqn(service.id)} {`);
1136
+ deployment.push(`${nsIndent} description ${q(about)}`);
1137
+ deployment.push(`${nsIndent}}`);
1138
+ const places = placesOfService.get(service.id) ?? [];
1139
+ places.push([...nsPath, instanceId].join("."));
1140
+ placesOfService.set(service.id, places);
1141
+ const frames = framesOfEnvironment.get(env) ?? new Set();
1142
+ for (let depth = 2; depth <= nsPath.length; depth += 1) frames.add(nsPath.slice(0, depth).join("."));
1143
+ frames.add([...nsPath, instanceId].join("."));
1144
+ framesOfEnvironment.set(env, frames);
1145
+ }
1146
+ if (namespace) deployment.push(`${indent}}`);
1147
+ }
1148
+ if (cluster) deployment.push(" }");
1149
+ }
1150
+ deployment.push(" }");
1151
+ }
1152
+ deployment.push("}");
1153
+
1154
+ views.push("");
1155
+ for (const env of environments) {
1156
+ // Every frame under the environment, named one by one rather than as
1157
+ // `env.**`: a descendant wildcard draws the instances alone and folds
1158
+ // the cluster and namespace away, and where a thing runs is the point.
1159
+ views.push(` deployment view ${deploymentViewId(env)} {`);
1160
+ views.push(` title ${q(`${env} — deployed`)}`);
1161
+ views.push(` include ${[...framesOfEnvironment.get(env)].join(", ")}`);
1162
+ views.push(" }");
1163
+ }
1164
+ for (const service of allServices) {
1165
+ const places = placesOfService.get(service.id);
1166
+ if (!places) continue;
1167
+ // The frames around each instance, named one by one: a node named with
1168
+ // its children is drawn as the frame it is, and a reader can tell the
1169
+ // two boxes apart by where they sit.
1170
+ const frames = new Set();
1171
+ for (const place of places) {
1172
+ const segments = place.split(".");
1173
+ for (let depth = 1; depth <= segments.length; depth += 1) frames.add(segments.slice(0, depth).join("."));
1174
+ }
1175
+ views.push(` deployment view ${serviceDeployViewId(service)} {`);
1176
+ views.push(` title ${q(`${service.name} — where it runs`)}`);
1177
+ views.push(` include ${[...frames].join(", ")}`);
1178
+ views.push(" }");
1179
+ }
1180
+ }
1053
1181
  views.push("}");
1054
1182
 
1055
- mkdirSync("likec4", { recursive: true });
1056
- writeFileSync("likec4/spec.c4", `${spec.join("\n")}\n`);
1057
- writeFileSync(
1058
- "likec4/model.c4",
1059
- `// GENERATED — do not edit.\n${model.join("\n")}\n`,
1060
- );
1061
- writeFileSync(
1062
- "likec4/views.c4",
1063
- `// GENERATED — do not edit.\n${views.join("\n")}\n`,
1064
- );
1183
+ return [
1184
+ { name: "deployment.c4", contents: `// GENERATED — do not edit.\n${deployment.join("\n")}\n` },
1185
+ { name: "spec.c4", contents: `${spec.join("\n")}\n` },
1186
+ { name: "model.c4", contents: `// GENERATED — do not edit.\n${model.join("\n")}\n` },
1187
+ { name: "views.c4", contents: `// GENERATED — do not edit.\n${views.join("\n")}\n` },
1188
+ ];
1189
+ }
1065
1190
 
1066
- console.log(
1067
- `wrote likec4/spec.c4, likec4/model.c4, likec4/views.c4 ` +
1068
- `(${catalog.flows.length * 2} dynamic views, ${relations.length} relations)`,
1069
- );
1191
+ // Run as a script - `npm run likec4:gen`, before the dev server starts - the
1192
+ // sources are written under likec4/ here and now.
1193
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
1194
+ const bundle = await loadCatalog();
1195
+ const files = await likec4Sources(bundle);
1196
+ mkdirSync("likec4", { recursive: true });
1197
+ for (const file of files) writeFileSync(join("likec4", file.name), file.contents);
1198
+ console.log(
1199
+ `wrote ${files.map((file) => `likec4/${file.name}`).join(", ")} ` +
1200
+ `(${bundle.catalog.flows.length * 2} dynamic views)`,
1201
+ );
1202
+ }
@@ -6,6 +6,8 @@ import { execFileSync } from "node:child_process";
6
6
 
7
7
  import { describe, expect, it } from "vitest";
8
8
 
9
+ import { likec4Sources } from "./gen-likec4.mjs";
10
+
9
11
  const generator = fileURLToPath(new URL("./gen-likec4.mjs", import.meta.url));
10
12
  const likec4 = join(
11
13
  dirname(dirname(generator)),
@@ -38,9 +40,22 @@ function generate(catalog) {
38
40
  spec: readFileSync(join(root, "likec4", "spec.c4"), "utf8"),
39
41
  model: readFileSync(join(root, "likec4", "model.c4"), "utf8"),
40
42
  views: readFileSync(join(root, "likec4", "views.c4"), "utf8"),
43
+ deployment: readFileSync(join(root, "likec4", "deployment.c4"), "utf8"),
41
44
  };
42
45
  }
43
46
 
47
+ describe("the LikeC4 generator as a step of gen", () => {
48
+ it("hands back the four sources as files rather than writing them, so gen can settle them", async () => {
49
+ const files = await likec4Sources({
50
+ catalog: { generatedAt: "2026-09-06T00:00:00Z", commit: "0", adrs: [], defs: {}, contexts: [], flows: [] },
51
+ manifest: { sources: ["data/*.json"] },
52
+ });
53
+ expect(files.map((file) => file.name)).toEqual(["deployment.c4", "spec.c4", "model.c4", "views.c4"]);
54
+ expect(files.find((file) => file.name === "views.c4")?.contents).toContain("view landscape");
55
+ expect(files.every((file) => file.contents.endsWith("\n"))).toBe(true);
56
+ });
57
+ });
58
+
44
59
  describe("the LikeC4 generator", () => {
45
60
  it("emits valid fallback views for an empty catalog", () => {
46
61
  const { model, views } = generate({ contexts: [], flows: [] });
@@ -432,4 +447,85 @@ describe("the LikeC4 generator", () => {
432
447
  /view svc_shop_oms of shop\.oms \{\n title[^\n]*\n include \*, -> \*, \* ->\n \}/,
433
448
  );
434
449
  });
450
+
451
+ it("places every deployed service in its environment, cluster and namespace, and draws the frames by name", () => {
452
+ const service = (slug, path) => ({
453
+ id: `shop.${slug}`,
454
+ slug,
455
+ name: slug,
456
+ repo: "github.com/acme/shop",
457
+ path,
458
+ readme: "",
459
+ provides: [],
460
+ consumes: [],
461
+ aggregates: [],
462
+ });
463
+ const placed = (name, environment, cluster, namespace, path) => ({
464
+ id: `argocd/${name}`,
465
+ name,
466
+ project: "shop",
467
+ environment,
468
+ cluster,
469
+ namespace,
470
+ repo: "github.com/acme/shop",
471
+ path,
472
+ targetRevision: "main",
473
+ revision: "a".repeat(40),
474
+ tool: "kustomize",
475
+ url: `https://argocd.example.com/applications/argocd/${name}`,
476
+ });
477
+ const { spec, deployment, views } = generate({
478
+ contexts: [
479
+ {
480
+ id: "shop",
481
+ slug: "shop",
482
+ name: "Shop",
483
+ summary: "",
484
+ services: [service("cart", "services/cart"), service("oms", "services/oms")],
485
+ },
486
+ ],
487
+ flows: [],
488
+ deployments: [
489
+ placed("cart", "prod", "in-cluster", "shop", "services/cart/deploy"),
490
+ placed("cart-staging", "staging", "staging-eu", "shop", "services/cart/deploy"),
491
+ placed("oms", "prod", "in-cluster", "shop", "services/oms/deploy"),
492
+ // Deploys a repository nobody in the estate claims: placed nowhere.
493
+ placed("grafana", "prod", "in-cluster", "monitoring", "charts/grafana"),
494
+ ],
495
+ });
496
+ expect(spec).toContain("deploymentNode environment");
497
+ expect(deployment).toContain(
498
+ " environment prod 'prod' {\n" +
499
+ " cluster in_cluster 'in-cluster' {\n" +
500
+ " namespace shop 'shop' {\n" +
501
+ " shop_cart = instanceOf shop.cart {\n" +
502
+ " description 'cart at aaaaaaa'\n" +
503
+ " }\n" +
504
+ " shop_oms = instanceOf shop.oms {",
505
+ );
506
+ expect(deployment).toContain("environment staging 'staging'");
507
+ expect(deployment).not.toContain("grafana");
508
+ expect(deployment).not.toContain("monitoring");
509
+ // The environment view names every frame under it; a descendant wildcard
510
+ // would fold the cluster and the namespace away.
511
+ expect(views).toContain(
512
+ " deployment view deploy_prod {\n" +
513
+ " title 'prod — deployed'\n" +
514
+ " include prod.in_cluster, prod.in_cluster.shop, prod.in_cluster.shop.shop_cart, prod.in_cluster.shop.shop_oms\n" +
515
+ " }",
516
+ );
517
+ expect(views).toContain(
518
+ " deployment view deploy_svc_shop_cart {\n" +
519
+ " title 'cart — where it runs'\n" +
520
+ " include prod, prod.in_cluster, prod.in_cluster.shop, prod.in_cluster.shop.shop_cart, staging, staging.staging_eu, staging.staging_eu.shop, staging.staging_eu.shop.shop_cart\n" +
521
+ " }",
522
+ );
523
+ expect(views).not.toContain("deploy_svc_shop_grafana");
524
+ });
525
+
526
+ it("writes an empty deployment file and no deployment view when nothing is placed", () => {
527
+ const { deployment, views } = generate({ contexts: [], flows: [] });
528
+ expect(deployment.trim()).toBe("// GENERATED — do not edit.");
529
+ expect(views).not.toContain("deployment view");
530
+ });
435
531
  });
package/scripts/gen.mjs CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from "./output-path.mjs";
37
37
  import { builtinPlugin } from "./builtin-plugins.mjs";
38
38
  import { diagnoseWarnings } from "./warning-policy.mjs";
39
+ import { likec4Sources } from "./gen-likec4.mjs";
39
40
 
40
41
  const PORTOLAN_VERSION = "0.1.0";
41
42
  const EVENTS = process.env.PORTOLAN_EVENTS === "1";
@@ -63,6 +64,10 @@ function event(value) {
63
64
  // in one run is drift that never happened.
64
65
  const MANIFEST = ".portolan-manifest";
65
66
 
67
+ // The one step no manifest declares: the app's own pictures, generated from
68
+ // every source, keyed by its own name in likec4/.portolan-manifest.
69
+ const LIKEC4_STEP = { plugin: "likec4", out: "likec4", key: "likec4" };
70
+
66
71
  const check = process.argv.includes("--check");
67
72
  const manifestSha256 = createHash("sha256")
68
73
  .update(readFileSync("portolan.json"))
@@ -194,6 +199,15 @@ async function generate() {
194
199
  { inputs: generated.sources.map((source) => source.path), excludes: [] });
195
200
  }
196
201
 
202
+ // The LikeC4 sources are pages of the catalog like any the generators
203
+ // write: committed, reviewed as a diff, and held to the catalog by the
204
+ // check. They used to be written only before the dev server started,
205
+ // which left a flow a verifier had just changed with a picture of the
206
+ // flow before - and a run from the page with no picture of a new flow.
207
+ await executeStep("generate", LIKEC4_STEP, "likec4 → likec4", async () => ({
208
+ files: await likec4Sources({ catalog, manifest }),
209
+ }), { inputs: sources.map((source) => source.path), excludes: [] });
210
+
197
211
  sweepAll();
198
212
  }
199
213
 
@@ -208,6 +222,17 @@ function sweepAll() {
208
222
  }
209
223
  }
210
224
 
225
+ /**
226
+ * The keys of the steps that write into a directory this run: the manifest's,
227
+ * and the likec4 step's in its own directory, which no manifest declares and
228
+ * the sweep would otherwise take for a step that was dropped.
229
+ */
230
+ function liveKeysIn(out) {
231
+ const live = new Set(keys.liveIn(out));
232
+ if (out === LIKEC4_STEP.out) live.add(LIKEC4_STEP.key);
233
+ return live;
234
+ }
235
+
211
236
  /** Every file the sweep would remove, in every directory a step writes into. */
212
237
  function staleAll() {
213
238
  const outs = new Set(
@@ -230,7 +255,7 @@ function staleAll() {
230
255
  */
231
256
  function staleIn(out) {
232
257
  const listing = previous(out);
233
- const live = keys.liveIn(out);
258
+ const live = liveKeysIn(out);
234
259
  const dead = Object.keys(listing).filter((key) => !live.has(key));
235
260
  if (dead.length === 0) return [];
236
261
 
@@ -267,7 +292,7 @@ async function executeStep(phase, step, label, work, reads) {
267
292
  project: projectForStep(step),
268
293
  phase,
269
294
  });
270
- const changes = apply(files, step.out, keys.keyOf(step), check);
295
+ const changes = apply(files, step.out, step.key ?? keys.keyOf(step), check);
271
296
  const since = changes.length > 0 ? whyChanged(step, files.map((file) => join(step.out, file.name)), reads) : null;
272
297
  const changed = summarise(label, files, changes, diagnostics, since);
273
298
  drifted = changed || drifted;
@@ -436,6 +461,8 @@ function whyChanged(step, outputs, reads) {
436
461
 
437
462
  /** Whether the manifest at `commit` told this step the same thing it is told now. */
438
463
  function stepEntryChanged(commit, step) {
464
+ // A step no manifest declares has no entry to have changed.
465
+ if (step.key) return false;
439
466
  let then;
440
467
  try {
441
468
  then = JSON.parse(fileAt(process.cwd(), commit, "portolan.json"));
@@ -552,7 +579,7 @@ function apply(files, out, key, checkOnly) {
552
579
  function sweep(out, checkOnly) {
553
580
  const changes = [];
554
581
  const listing = previous(out);
555
- const live = keys.liveIn(out);
582
+ const live = liveKeysIn(out);
556
583
  const dead = Object.keys(listing).filter((key) => !live.has(key));
557
584
  if (dead.length === 0) return changes;
558
585
 
@@ -0,0 +1,108 @@
1
+ // The example GitOps tree, rendered the way Argo CD would render it.
2
+ //
3
+ // Every overlay under examples/gitops/envs/*/shop/* is built with kustomize
4
+ // - the binary when it is on the PATH, kubectl's copy otherwise - and what
5
+ // the environment says (the tag, the replica count, the variable) has to be
6
+ // what comes out. Then the tree is held to the snapshot in examples/argocd:
7
+ // an overlay that no Application in the snapshot deploys, or an Application
8
+ // pointing at a directory that is not here, is the two examples drifting
9
+ // apart, which is the one thing an example must not do.
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ const root = fileURLToPath(new URL("..", import.meta.url));
17
+ const gitops = join(root, "examples", "gitops");
18
+
19
+ /** kustomize itself, else the copy kubectl ships; the output is the same. */
20
+ function kustomizeCommand() {
21
+ for (const candidate of ["kustomize"]) {
22
+ try {
23
+ execFileSync(candidate, ["version"], { stdio: "ignore" });
24
+ return [candidate, "build"];
25
+ } catch {
26
+ // Not on the PATH.
27
+ }
28
+ }
29
+ return ["kubectl", "kustomize"];
30
+ }
31
+
32
+ const [command, ...args] = kustomizeCommand();
33
+ const build = (dir) => execFileSync(command, [...args, dir], { encoding: "utf8" });
34
+
35
+ /** `<env>/shop/<service>` for every overlay in the tree, sorted. */
36
+ function overlays() {
37
+ const out = [];
38
+ for (const env of readdirSync(join(gitops, "envs")).sort()) {
39
+ const shop = join(gitops, "envs", env, "shop");
40
+ if (!existsSync(shop)) continue;
41
+ for (const service of readdirSync(shop).sort()) {
42
+ if (existsSync(join(shop, service, "kustomization.yaml"))) out.push({ env, service, dir: join(shop, service) });
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ describe("the example GitOps tree", () => {
49
+ it("has an overlay for cart in both environments and pricing in prod only", () => {
50
+ expect(overlays().map((o) => `${o.env}/${o.service}`)).toEqual(["prod/cart", "prod/pricing", "staging/cart"]);
51
+ });
52
+
53
+ it("renders cart in prod at the tag prod runs, three replicas, labelled with the environment", () => {
54
+ const rendered = build(join(gitops, "envs", "prod", "shop", "cart"));
55
+ expect(rendered).toContain("image: ghcr.io/shortlink-org/cart:2.1.0");
56
+ expect(rendered).toContain("replicas: 3");
57
+ expect(rendered).toContain("env: prod");
58
+ expect(rendered).toContain("app.kubernetes.io/part-of: shop");
59
+ // The base's names and namespace survive the overlay.
60
+ expect(rendered).toMatch(/kind: Ingress[\s\S]*namespace: shop/);
61
+ });
62
+
63
+ it("renders cart in staging as the release candidate, one replica, NODE_ENV restated and nothing else lost", () => {
64
+ const rendered = build(join(gitops, "envs", "staging", "shop", "cart"));
65
+ expect(rendered).toContain("image: ghcr.io/shortlink-org/cart:2.2.0-rc.1");
66
+ expect(rendered).toContain("replicas: 1");
67
+ expect(rendered).toMatch(/name: NODE_ENV\n\s+value: staging/);
68
+ // Merged by name: the other variables the base declares are still there.
69
+ expect(rendered).toMatch(/name: PRICING_ADDR\n\s+value: pricing\.shop\.svc:9090/);
70
+ expect(rendered).not.toContain("value: production");
71
+ });
72
+
73
+ it("renders pricing in prod with the longer cache and the secret untouched", () => {
74
+ const rendered = build(join(gitops, "envs", "prod", "shop", "pricing"));
75
+ expect(rendered).toContain("image: ghcr.io/shortlink-org/pricing:1.4.2");
76
+ expect(rendered).toContain("PRICE_LIST_TTL: 1h");
77
+ expect(rendered).toContain("OTEL_EXPORTER_OTLP_ENDPOINT");
78
+ expect(rendered).toContain("kind: Secret");
79
+ });
80
+
81
+ it("is what the snapshot in examples/argocd was recorded from", () => {
82
+ const snapshot = JSON.parse(readFileSync(join(root, "examples", "argocd", "argocd.apps.json"), "utf8"));
83
+ const placed = snapshot.deployments.filter((d) => d.project === "shop");
84
+ // One Application per overlay, named as the ApplicationSet names it,
85
+ // pointing at the overlay, labelled onto the service.
86
+ expect(placed.map((d) => [d.name, d.environment, d.path, d.service]).sort()).toEqual(
87
+ overlays().map((o) => [`shop-${o.service}-${o.env}`, o.env, `examples/gitops/envs/${o.env}/shop/${o.service}`, `shop.${o.service}`]).sort(),
88
+ );
89
+ // The image the snapshot says runs is the image the overlay renders.
90
+ for (const deployment of placed) {
91
+ const rendered = build(join(root, deployment.path));
92
+ for (const image of deployment.images ?? []) expect(rendered).toContain(`image: ${image}`);
93
+ }
94
+ // The clusters the environments name are the ones the snapshot places in.
95
+ for (const { env } of overlays()) {
96
+ const cluster = readFileSync(join(gitops, "envs", env, "cluster.yaml"), "utf8").match(/^\s+name: (.+)$/m)?.[1];
97
+ for (const deployment of placed.filter((d) => d.environment === env)) expect(deployment.cluster).toBe(cluster);
98
+ }
99
+ });
100
+
101
+ it("names every Application in the shop ApplicationSet with the labels the fetcher places by", () => {
102
+ const appset = readFileSync(join(gitops, "appsets", "shop.yaml"), "utf8");
103
+ expect(appset).toContain("app.kubernetes.io/part-of: shop");
104
+ expect(appset).toContain('app.kubernetes.io/name: "{{.path.basename}}"');
105
+ expect(appset).toContain('name: "shop-{{.path.basename}}-{{.env}}"');
106
+ expect(appset).toContain("path: examples/gitops/envs/*/cluster.yaml");
107
+ });
108
+ });