@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
package/scripts/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Host
2
2
 
3
3
  The side of portolan that runs. `npm run gen` reads the manifest, runs every
4
- plugin it names, stamps and writes what comes back, merges the sources into
5
- one estate and validates the union. Nothing here draws anything.
4
+ plugin it names, writes what comes back, merges the sources into one estate
5
+ and validates the union. Nothing here draws anything.
6
6
 
7
7
  ## What it does
8
8
 
@@ -10,21 +10,25 @@ one estate and validates the union. Nothing here draws anything.
10
10
  the schema `npm run schema` composes out of what every declared plugin says
11
11
  it can be told.
12
12
  - Runs three phases in order, extract, verify, generate, and hands each
13
- plugin one JSON message: the input root, output directory and stamp for an
13
+ plugin one JSON message: the input root and output directory for an
14
14
  extractor, the merged catalog for a verifier or a generator, the step's
15
15
  options unread. The output path lets a fragment point at a companion file
16
16
  returned in the same response without letting the plugin write it itself.
17
- - Stamps a fragment from the last commit that touched its input, never from a
18
- clock, so a committed fragment changes exactly when its subject does
19
- (portolan.0002).
17
+ - Writes no provenance into a fragment. When a source last changed, and in
18
+ which commit, is read from the history of the checkout wherever the catalog
19
+ is read - here, by the LikeC4 generator, by the architecture diff and by
20
+ the site as Vite builds it - so a fragment is content and nothing else, and
21
+ regenerating from the same sources changes no byte (portolan.0010).
20
22
  - Writes the files a plugin named, refuses a name that climbs out of the
21
23
  step's output directory, keeps a listing per step of what it wrote, and
22
24
  deletes what stopped being generated (portolan.0001).
23
25
  - Merges every source the manifest's globs find and validates the union with
24
26
  the code the site runs: `src/merge.ts`, `src/catalog.ts`, `src/enrich.ts`.
25
27
  - In `--check` mode writes nothing and fails on the first file that differs
26
- from disk. Either way it leaves `.portolan/build-report.json` for the
27
- Settings page.
28
+ from disk, saying where the file first differs and what changed among the
29
+ step's inputs since its output was last committed - the history is the
30
+ record of the last generation, so nothing else has to be. Either way it
31
+ leaves `.portolan/build-report.json` for the Settings page.
28
32
 
29
33
  ## What it does not do
30
34
 
@@ -33,7 +37,9 @@ one estate and validates the union. Nothing here draws anything.
33
37
  - Read a plugin's stdout for anything but the one message, or its stderr for
34
38
  anything but notes; a warning is kept beside the step in the build report.
35
39
  - Draw the C4 model: `gen-likec4.mjs` writes `likec4/` from the same merged
36
- 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`.
37
43
 
38
44
  ## Files
39
45
 
@@ -47,7 +53,9 @@ one estate and validates the union. Nothing here draws anything.
47
53
  that report put on the pull request as one comment kept current;
48
54
  `forge-release.mjs`, the same report as one section of a release's notes;
49
55
  `forge.mjs`, what those two share - which forge the CI is, and how to talk
50
- to it; `vendor-lock.mjs`,
51
- the commit a fetched copy is of; `site-docs.mjs`, generated documentation
52
- put into the built site; `local-api.mjs`, what the dev server answers the
53
- site with.
56
+ to it; `history.mjs`, what the checkout's history says about every file,
57
+ for a plugin that asks and for the provenance of every source;
58
+ `provenance.mjs`, that provenance handed to the site as one virtual module;
59
+ `output-diff.mjs`, where a generated file first differs from what the
60
+ generator produces; `site-docs.mjs`, generated documentation put into the
61
+ built site; `local-api.mjs`, what the dev server answers the site with.
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
14
14
  import { filterCatalogForProfile } from "../src/catalog-profile.ts";
15
15
  import { enrichCatalog } from "../src/enrich.ts";
16
16
  import { mergeCatalogs } from "../src/merge.ts";
17
+ import { stampsFor } from "./history.mjs";
17
18
  import { readManifest } from "./manifest.mjs";
18
19
 
19
20
  /**
@@ -48,10 +49,15 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
48
49
  );
49
50
  }
50
51
 
52
+ // A source is dated by the history, not by itself (portolan.0010): the
53
+ // commit that last changed the file, and its date, read here and never
54
+ // written into the file.
55
+ const stamps = stampsFor(process.cwd(), paths);
51
56
  const merged = mergeCatalogs(
52
57
  paths.map((path) => ({
53
58
  path,
54
59
  catalog: JSON.parse(readFileSync(path, "utf8")),
60
+ stamp: stamps.get(path),
55
61
  })),
56
62
  );
57
63
  if (paths.length === 0) {
@@ -23,7 +23,10 @@ const GITLAB_START = "# >>> Portolan delivery preset >>>";
23
23
  const GITLAB_END = "# <<< Portolan delivery preset <<<";
24
24
  const PROVIDERS = new Set(["github", "gitlab"]);
25
25
  const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
26
- const DEFAULT_FEATURES = ["check", "pages"];
26
+ const DEFAULT_FEATURES = {
27
+ github: ["check", "pages"],
28
+ gitlab: ["pages"],
29
+ };
27
30
  const FEATURE_DETAILS = {
28
31
  check: {
29
32
  label: "Architecture check",
@@ -76,11 +79,23 @@ function repositoryName(remote, workspace) {
76
79
  return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
77
80
  }
78
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.
79
86
  function defaultBranch(workspace) {
80
- const remoteHead = git(workspace, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
81
- 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);
82
97
  for (const candidate of ["main", "master"]) {
83
- if (git(workspace, ["rev-parse", "--verify", `refs/heads/${candidate}`])) return candidate;
98
+ if (refs.has(`refs/heads/${candidate}`)) return candidate;
84
99
  }
85
100
  return git(workspace, ["branch", "--show-current"]) || "main";
86
101
  }
@@ -225,7 +240,9 @@ ${upload}`;
225
240
 
226
241
  function gitlabCheck() {
227
242
  return `"portolan:check":
228
- stage: .pre
243
+ stage: test
244
+ tags:
245
+ - runner-type:docker
229
246
  image:
230
247
  name: ghcr.io/shortlink-org/portolan:${VERSION}
231
248
  entrypoint: [""]
@@ -242,7 +259,9 @@ function gitlabCheck() {
242
259
  function gitlabDiff({ pages }) {
243
260
  const site = pages ? ' --site "$CI_PAGES_URL"' : "";
244
261
  return `"portolan:review":
245
- stage: .pre
262
+ stage: test
263
+ tags:
264
+ - runner-type:docker
246
265
  image:
247
266
  name: ghcr.io/shortlink-org/portolan:${VERSION}
248
267
  entrypoint: [""]
@@ -259,8 +278,10 @@ function gitlabDiff({ pages }) {
259
278
  }
260
279
 
261
280
  function gitlabPages() {
262
- return `"portolan:pages":
263
- stage: .post
281
+ return `pages:
282
+ stage: deploy
283
+ tags:
284
+ - runner-type:docker
264
285
  image:
265
286
  name: ghcr.io/shortlink-org/portolan:${VERSION}
266
287
  entrypoint: [""]
@@ -268,9 +289,10 @@ function gitlabPages() {
268
289
  GIT_DEPTH: "0"
269
290
  script:
270
291
  - BASE_PATH="$(node -p 'new URL(process.env.CI_PAGES_URL).pathname.replace(/\\/?$/, "/") || "/"')"
271
- - portolan build --output dist --base "$BASE_PATH"
272
- pages:
273
- publish: dist
292
+ - portolan build --output public --base "$BASE_PATH"
293
+ artifacts:
294
+ paths:
295
+ - public
274
296
  rules:
275
297
  - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
276
298
  `;
@@ -299,15 +321,36 @@ function mergeGitlab(existing, features) {
299
321
  .replace(/^\n+|\n+$/g, "");
300
322
  return { content: content ? `${content}\n` : "" };
301
323
  }
302
- if (/^[ \t]*["']?portolan:(?:check|review|pages)["']?\s*:/m.test(existing)) {
324
+ if (/^(?:["']?portolan:(?:check|review|pages)["']?|["']?pages["']?)\s*:/m.test(existing)) {
303
325
  return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
304
326
  }
305
327
  const block = gitlabBlock(features);
306
328
  return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
307
329
  }
308
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
+
309
350
  function textDiff(path, before, after) {
310
351
  if (before === after) return "";
352
+ if (before === "") return wholeFileDiff(path, after, "+");
353
+ if (after === "") return wholeFileDiff(path, before, "-");
311
354
  const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
312
355
  const left = join(holder, "before");
313
356
  const right = join(holder, "after");
@@ -421,14 +464,14 @@ function installedFeatures(workspace, provider) {
421
464
  );
422
465
  if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
423
466
  if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
424
- if (/^[ \t]*["']?portolan:pages["']?\s*:/m.test(managed)) selected.add("pages");
467
+ if (/^(?:["']?portolan:pages["']?|["']?pages["']?)\s*:/m.test(managed)) selected.add("pages");
425
468
  return selected;
426
469
  }
427
470
 
428
471
  function selectedFeatures(workspace, request, provider) {
429
472
  const installed = request.features == null ? installedFeatures(workspace, provider) : null;
430
473
  const raw = request.features == null
431
- ? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
474
+ ? installed.size > 0 ? [...installed] : DEFAULT_FEATURES[provider]
432
475
  : Array.isArray(request.features)
433
476
  ? request.features
434
477
  : String(request.features).split(",").filter(Boolean);
package/scripts/diff.mjs CHANGED
@@ -157,8 +157,12 @@ function catalogAt(ref) {
157
157
  throw new Error(ref + " holds no catalog sources matching " + JSON.stringify(manifest.sources));
158
158
  }
159
159
 
160
+ // The base is one commit, and a commit is its own provenance: every source
161
+ // there is dated by it (portolan.0010), whatever an older fragment says.
162
+ const [commit = "", generatedAt = ""] = git(["log", "-1", "--format=%h %cI", ref]).split(" ");
163
+ const stamp = { commit, generatedAt };
160
164
  const merged = mergeCatalogs(
161
- paths.map((path) => ({ path, catalog: JSON.parse(git(["show", ref + ":" + path])) })),
165
+ paths.map((path) => ({ path, catalog: JSON.parse(git(["show", ref + ":" + path])), stamp })),
162
166
  );
163
167
 
164
168
  // Enriched before it is compared, exactly as the app and the generators see
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { djangoAggregateProposals, saveDjangoAggregates } from "./local-api.mjs";
7
+
8
+ const roots = [];
9
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
10
+ function fixture() {
11
+ const root = mkdtempSync(join(tmpdir(), "portolan-aggregates-")); roots.push(root);
12
+ const manifest = { sources: ["data/*.json"], extract: ["one", "two"].map((name) => ({ plugin: "django-domain", in: name, out: `data/${name}`, options: { context: name, service: name, aggregates: { existing: "Existing" }, apps: ["billing.records"] } })) };
13
+ const text = JSON.stringify(manifest);
14
+ writeFileSync(join(root, "portolan.json"), text);
15
+ const message = `billing/records: no model called Records, and 2 models to choose from: name the root in the aggregates option; aggregate candidates: ${JSON.stringify({ app: "billing.records", models: [{ name: "Entry", path: "billing/records/models.py", line: 1 }, { name: "Audit", path: "billing/records/models.py", line: 10 }] })}`;
16
+ const report = { status: "ok", manifestSha256: createHash("sha256").update(text).digest("hex"), steps: manifest.extract.map((step) => ({ phase: "extract", plugin: step.plugin, input: step.in, output: step.out, warnings: [message] })) };
17
+ mkdirSync(join(root, ".portolan")); writeFileSync(join(root, ".portolan/build-report.json"), JSON.stringify(report));
18
+ return { root, manifest, report };
19
+ }
20
+
21
+ describe("saving Django aggregate roots", () => {
22
+ it("targets the precise step and dotted app, preserving unrelated options", () => {
23
+ const { root, manifest } = fixture();
24
+ const proposals = djangoAggregateProposals(root);
25
+ expect(proposals.stale).toBe(false);
26
+ expect(proposals.proposals).toHaveLength(2);
27
+ saveDjangoAggregates(root, { revision: proposals.revision, selections: [{ id: "1:billing.records", model: "Entry" }] });
28
+ const saved = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
29
+ expect(saved.extract[0]).toEqual(manifest.extract[0]);
30
+ expect(saved.extract[1].options).toEqual({ ...manifest.extract[1].options, aggregates: { existing: "Existing", "billing.records": "Entry" } });
31
+ expect(djangoAggregateProposals(root).stale).toBe(true);
32
+ });
33
+
34
+ it("saves a batch atomically and rejects forged, duplicate, or stale choices", () => {
35
+ const { root } = fixture();
36
+ const { revision } = djangoAggregateProposals(root);
37
+ const original = readFileSync(join(root, "portolan.json"), "utf8");
38
+ for (const selections of [[], [{ id: "0:billing.records", model: "Proxy" }], [{ id: "no-such-step", model: "Entry" }], [{ id: "0:billing.records", model: "Entry" }, { id: "1:billing.records", model: "Invalid" }], [{ id: "0:billing.records", model: "Entry" }, { id: "0:billing.records", model: "Audit" }]]) {
39
+ expect(() => saveDjangoAggregates(root, { revision, selections })).toThrow();
40
+ expect(readFileSync(join(root, "portolan.json"), "utf8")).toBe(original);
41
+ }
42
+ const selections = [{ id: "0:billing.records", model: "Entry" }, { id: "1:billing.records", model: "Audit" }];
43
+ expect(() => saveDjangoAggregates(root, { revision: "old", selections })).toThrow(/changed/);
44
+ expect(saveDjangoAggregates(root, { revision, selections })).toEqual({ saved: 2 });
45
+ expect(() => saveDjangoAggregates(root, { revision, selections })).toThrow(/changed/);
46
+ });
47
+
48
+ it("requires fresh report evidence and refuses ambiguous step bindings", () => {
49
+ const { root, manifest, report } = fixture();
50
+ manifest.extract.push(manifest.extract[0]);
51
+ writeFileSync(join(root, "portolan.json"), JSON.stringify(manifest));
52
+ expect(djangoAggregateProposals(root).proposals.map((p) => p.id)).toEqual(["1:billing.records"]);
53
+ expect(djangoAggregateProposals(root).stale).toBe(true);
54
+ report.steps = [];
55
+ writeFileSync(join(root, ".portolan/build-report.json"), JSON.stringify(report));
56
+ expect(djangoAggregateProposals(root).proposals).toEqual([]);
57
+ });
58
+ });
@@ -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
 
@@ -278,7 +300,7 @@ for (const context of catalog.contexts) {
278
300
  model.push(` style { color ${contextColorName(context.id)} }`);
279
301
  for (const aggregate of service.aggregates) {
280
302
  model.push(
281
- ` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.name)} {`,
303
+ ` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.kind === "model-group" ? `${aggregate.name} (model group)` : aggregate.name)} {`,
282
304
  );
283
305
  for (const event of aggregate.events) {
284
306
  const latest = event.versions[event.versions.length - 1];
@@ -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
  });