@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
@@ -1,17 +1,17 @@
1
- import { useMemo } from "react";
2
1
  import { NavLink } from "react-router";
3
2
  import {
4
3
  Check,
5
4
  PanelLeftOpen,
5
+ Puzzle,
6
6
  Settings2,
7
7
  TriangleAlert,
8
8
  } from "lucide-react";
9
9
 
10
10
  import { allModules } from "../catalog";
11
+ import { catalog } from "../data";
11
12
  import { KindIcon } from "../components/kind";
12
13
  import { CompassRose } from "../components/logo";
13
- import { catalog, index } from "../data";
14
- import { allProblems } from "../lib/all-problems";
14
+ import { useProblems } from "../lib/use-problems";
15
15
  import type { Kind } from "../lib/kinds";
16
16
  import { paths } from "../routes";
17
17
 
@@ -28,7 +28,7 @@ export function SidebarBottomGroup() {
28
28
  // opens. The badge is red while anything on it is an error and amber when
29
29
  // only the schema disagrees with itself - a page of warnings is not a clean
30
30
  // estate, and a green tick over it would be the one lie the row can tell.
31
- const found = useMemo(() => allProblems(catalog, index), []);
31
+ const found = useProblems();
32
32
  const errors = found.filter((p) => p.severity === "error").length;
33
33
  const colour =
34
34
  errors > 0 ? "var(--status-unresolved)" : "var(--status-declared)";
@@ -76,6 +76,22 @@ export function SidebarBottomGroup() {
76
76
  </span>
77
77
  )}
78
78
  </NavLink>
79
+ <NavLink
80
+ to={paths.plugins()}
81
+ data-nav-item
82
+ title="Plugins — what Portolan can read, and what it makes"
83
+ style={({ isActive }) => ({
84
+ paddingLeft: indent(0),
85
+ background: isActive ? "var(--surface-2)" : undefined,
86
+ borderLeftWidth: 2,
87
+ borderLeftStyle: "solid",
88
+ borderLeftColor: isActive ? "var(--accent)" : "transparent",
89
+ })}
90
+ className="tree-row flex items-center gap-1.5 py-[3px] pr-2 text-muted t-micro transition-colors hover:bg-surface hover:text-ink"
91
+ >
92
+ <Puzzle size={14} aria-hidden className="block shrink-0" />
93
+ <span className="truncate">Plugins</span>
94
+ </NavLink>
79
95
  <NavLink
80
96
  to={paths.settings()}
81
97
  data-nav-item
@@ -0,0 +1,64 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, 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
+ import { catalogDocs } from "./catalog-docs";
6
+ // @ts-expect-error The build's documentation mount is a plain JavaScript module.
7
+ import { siteDocs } from "../scripts/site-docs.mjs";
8
+
9
+ const roots: string[] = [];
10
+ afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
11
+
12
+ describe("catalog documentation URLs", () => {
13
+ it.each(["default", "avia", "portolan"])("opens the files actually published for root catalog %s", (id) => {
14
+ const root = mkdtempSync(join(tmpdir(), "catalog-docs-"));
15
+ roots.push(root);
16
+ const out = join(root, "generated", "markdown");
17
+ const dist = join(root, "dist");
18
+ mkdirSync(out, { recursive: true });
19
+ mkdirSync(dist);
20
+ writeFileSync(join(out, "llms.txt"), "# Catalog\n[Home](README.md)\n");
21
+ writeFileSync(join(out, "llms-full.txt"), "# Full catalog\n");
22
+ writeFileSync(join(out, "README.md"), "# Home\n");
23
+ const manifest = { generate: [{ plugin: "markdown", ...(id === "default" ? {} : { catalog: id }), out }] };
24
+ siteDocs({ manifest, dist });
25
+
26
+ const urls = catalogDocs(manifest, id, "/portolan/")!;
27
+ expect(urls.index).toBe("/portolan/llms.txt");
28
+ const published = (url: string) => readFileSync(join(dist, url.slice("/portolan/".length)), "utf8");
29
+ expect(published(urls.index)).toContain("[Home](docs/README.md)");
30
+ expect(published(urls.full)).toBe("# Full catalog\n");
31
+ expect(published(`${urls.pages}README.md`)).toBe("# Home\n");
32
+ });
33
+
34
+ it("uses a nested output directory even when it differs from the catalog id", () => {
35
+ const manifest = { generate: [
36
+ { plugin: "markdown", catalog: "avia", out: "./generated/docs/" },
37
+ { plugin: "markdown", catalog: "example", out: "generated/docs/samples" },
38
+ ] };
39
+ expect(catalogDocs(manifest, "example", "/portolan")).toEqual({
40
+ pages: "/portolan/docs/samples/",
41
+ index: "/portolan/docs/samples/llms.txt",
42
+ full: "/portolan/docs/samples/llms-full.txt",
43
+ });
44
+ });
45
+
46
+ it("preserves the demo's example paths and supports a site served at /", () => {
47
+ const manifest = { generate: [
48
+ { plugin: "markdown", catalog: "portolan", out: "docs" },
49
+ { plugin: "markdown", catalog: "example", out: "docs/example" },
50
+ ] };
51
+ expect(catalogDocs(manifest, "example", "/")?.index).toBe("/docs/example/llms.txt");
52
+ expect(catalogDocs(manifest, "portolan", "/")?.index).toBe("/llms.txt");
53
+ });
54
+
55
+ it("does not invent URLs for missing or unmounted documentation", () => {
56
+ expect(catalogDocs({}, "default", "/")).toBeNull();
57
+ const manifest = { generate: [
58
+ { plugin: "markdown", catalog: "avia", out: "docs" },
59
+ { plugin: "markdown", catalog: "example", out: "docs-other" },
60
+ ] };
61
+ expect(catalogDocs(manifest, "unknown", "/")).toBeNull();
62
+ expect(catalogDocs(manifest, "example", "/")).toBeNull();
63
+ });
64
+ });
@@ -0,0 +1,35 @@
1
+ export interface DocsManifest {
2
+ generate?: { plugin: string; catalog?: string; out: string }[];
3
+ }
4
+
5
+ export interface CatalogDocs {
6
+ pages: string;
7
+ index: string;
8
+ full: string;
9
+ }
10
+
11
+ /** Match site-docs.mjs: the first markdown output is mounted at docs/.
12
+ * Other catalogs are available when generated inside that output directory.
13
+ */
14
+ export function catalogDocs(
15
+ manifest: DocsManifest,
16
+ profileId: string,
17
+ base: string,
18
+ ): CatalogDocs | null {
19
+ const steps = manifest.generate?.filter((step) => step.plugin === "markdown") ?? [];
20
+ const root = steps[0];
21
+ const selected = steps.find((step) => step.catalog === profileId)
22
+ ?? steps.find((step) => !step.catalog);
23
+ if (!root || !selected) return null;
24
+
25
+ const directory = (out: string) => new URL(`${out.replace(/\/$/, "")}/`, "https://workspace.invalid/").pathname;
26
+ const rootPath = directory(root.out);
27
+ const selectedPath = directory(selected.out);
28
+ if (!selectedPath.startsWith(rootPath)) return null;
29
+
30
+ const prefix = base.endsWith("/") ? base : `${base}/`;
31
+ const suffix = selectedPath.slice(rootPath.length);
32
+ const pages = `${prefix}docs/${suffix}`;
33
+ const indexRoot = suffix ? pages : prefix;
34
+ return { pages, index: `${indexRoot}llms.txt`, full: `${indexRoot}llms-full.txt` };
35
+ }
@@ -21,6 +21,21 @@ function failureOf(catalog: Catalog): CatalogError {
21
21
  }
22
22
 
23
23
  describe("CatalogError.path", () => {
24
+ it("allows rootless source groups while preserving aggregate root validation", () => {
25
+ const data = clone();
26
+ const aggregate = data.contexts[0]!.services[0]!.aggregates[0]!;
27
+ aggregate.root = "";
28
+ delete aggregate.lifecycle;
29
+ expect(() => validateCatalog(data)).toThrow(/names no root/);
30
+ aggregate.kind = "model-group";
31
+ expect(() => validateCatalog(data)).not.toThrow();
32
+ aggregate.root = aggregate.entities[0]!.name;
33
+ expect(() => validateCatalog(data)).toThrow(/cannot declare an aggregate root/);
34
+ aggregate.root = "";
35
+ aggregate.lifecycle = { states: ["Active"], transitions: [] };
36
+ expect(() => validateCatalog(data)).toThrow(/cannot declare an aggregate root or lifecycle/);
37
+ });
38
+
24
39
  it("names the flow and the step for a step that points at a missing lane", () => {
25
40
  const bad = clone();
26
41
  const flow = bad.flows[0];
@@ -6,6 +6,7 @@ import type {
6
6
  BoundedContext,
7
7
  Catalog,
8
8
  Column,
9
+ Deployment,
9
10
  Enum,
10
11
  Event,
11
12
  External,
@@ -21,12 +22,14 @@ import type {
21
22
  View,
22
23
  } from "./catalog-model.ts";
23
24
  import {
25
+ allDeployments,
24
26
  allExternals,
25
27
  allModules,
26
28
  allStores,
27
29
  allTerms,
28
30
  aggregateBlocks,
29
31
  columnId,
32
+ deploys,
30
33
  enumsOf,
31
34
  storeViews,
32
35
  viewReads,
@@ -140,6 +143,14 @@ export interface CatalogIndex {
140
143
  lineageInto: Map<string, string[]>;
141
144
  /** service id -> stores it owns, in catalog order */
142
145
  storesOwnedBy: Map<string, Store[]>;
146
+ /**
147
+ * service id -> where it runs, in snapshot order.
148
+ *
149
+ * Joined here rather than written by the fetcher, which reads a control
150
+ * plane and knows no service: an Application is this service's when it
151
+ * deploys from the service's repository, inside the service's directory.
152
+ */
153
+ deploymentsByService: Map<string, Deployment[]>;
143
154
  /** aggregate id -> tables naming it in `persists`, in catalog order */
144
155
  tablesByAggregate: Map<string, Table[]>;
145
156
  /** aggregate id -> views naming it in `persists`, in catalog order */
@@ -323,6 +334,19 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
323
334
  }
324
335
  }
325
336
 
337
+ // Deployments come after the services because the join runs over them: a
338
+ // row is placed on every service whose repository and directory it deploys
339
+ // from, and on none when nobody in the estate claims that repository.
340
+ const deploymentsByService = new Map<string, Deployment[]>();
341
+ for (const deployment of allDeployments(catalog)) {
342
+ for (const service of serviceById.values()) {
343
+ if (!deploys(deployment, service)) continue;
344
+ const placed = deploymentsByService.get(service.id) ?? [];
345
+ placed.push(deployment);
346
+ deploymentsByService.set(service.id, placed);
347
+ }
348
+ }
349
+
326
350
  // Stores come after the domain tree because they point into it: a table says
327
351
  // which aggregate it persists, and a column which block it maps to, so both
328
352
  // are resolved against maps that are already full.
@@ -467,6 +491,7 @@ export function buildIndex(catalog: Catalog): CatalogIndex {
467
491
  lineageFrom,
468
492
  lineageInto,
469
493
  storesOwnedBy,
494
+ deploymentsByService,
470
495
  moduleById,
471
496
  moduleBySlug,
472
497
  interfacesByModule,
@@ -44,6 +44,13 @@ export interface Catalog {
44
44
  * to pin and renders as it did before there was anything to pin.
45
45
  */
46
46
  repos?: RepoPin[];
47
+ /**
48
+ * Where the estate's services run, read from what deploys them. Optional
49
+ * in the file and never optional downstream, exactly like `repos`: an
50
+ * estate nobody has pointed at a deployer renders as it did before there
51
+ * was anything to place.
52
+ */
53
+ deployments?: Deployment[];
47
54
  /**
48
55
  * The systems outside the estate that a service calls on a contract: a
49
56
  * payment provider, a tax API, a carrier. Nobody here builds one, so it has
@@ -173,6 +180,24 @@ export interface Service {
173
180
  * same as a service that cannot be built.
174
181
  */
175
182
  commands?: Command[];
183
+ /**
184
+ * The names this service answers on, read from what deploys it: a
185
+ * Kubernetes Service's name in its short, namespaced, `svc` and fully
186
+ * qualified forms, and the hosts of the Ingress or HTTPRoute in front of
187
+ * it. Written so that a call another service is configured to make to
188
+ * `pricing.shop.svc` can find the service that answers. Absent when
189
+ * nothing in the tree says where the service is reachable.
190
+ */
191
+ hosts?: string[];
192
+ /**
193
+ * The in-cluster names this service's workload is configured to reach,
194
+ * read out of its environment and config maps and reduced to the host
195
+ * alone. A value is never kept: not the variable it came from, not the
196
+ * scheme, port, path or credentials around the name. Only a name the
197
+ * cluster resolves qualifies, so a password in an environment variable is
198
+ * not something this list can hold by shape.
199
+ */
200
+ dials?: string[];
176
201
  }
177
202
 
178
203
  /**
@@ -297,7 +322,11 @@ export interface RpcMethod {
297
322
  }
298
323
 
299
324
  export interface HttpRoute {
300
- /** Upper case: `POST`. */
325
+ /**
326
+ * Upper case: `POST`. Empty when a framework extractor proved the mount but
327
+ * no declaration proved the verb; such a route is never matched against an
328
+ * outbound call, and renderers show the path alone.
329
+ */
301
330
  method: string;
302
331
  /** As templated in the document: `/v1/users/{id}`. */
303
332
  path: string;
@@ -361,7 +390,32 @@ export interface EdgeVia {
361
390
  flow: string; // Flow.slug
362
391
  step: string; // Step.id
363
392
  }
393
+ /** Source facts remain separate from the merge's choice of provider. */
394
+ export interface HTTPDestination {
395
+ callSite: string;
396
+ endpointExpression: string;
397
+ method: string;
398
+ localPath?: string;
399
+ baseURL?: HTTPBaseURL;
400
+ serviceDiscoveryAlias?: string;
401
+ fullPath?: string;
402
+ join?: { expression: string; source: string };
403
+ /** Runtime URL modifiers after the proven join; not evaluated statically. */
404
+ transforms?: { expression: string; source: string }[];
405
+ resolution?: { basis: "full-path" | "exact-route" | "unique-suffix" | "kubernetes-host"; provider: string; route: string };
406
+ }
407
+ export interface HTTPBaseURL {
408
+ expression: string;
409
+ configField?: string;
410
+ environmentVariable?: string;
411
+ value?: string;
412
+ kind: "literal" | "config-default" | "symbolic";
413
+ source: string;
414
+ optionSource?: string;
415
+ }
364
416
  export interface RpcCall {
417
+ evidence?: RelationEvidence[];
418
+ destination?: HTTPDestination;
365
419
  id: string; // "<proto.package.Service>/<Method>"
366
420
  peer: string; // service id if resolved, else raw name
367
421
  status: Status;
@@ -442,12 +496,112 @@ export interface RepoPin {
442
496
  /** The commit the copy was made of. Full sha: it is not resolved locally, so there is nothing to expand it against. */
443
497
  commit: string;
444
498
  }
499
+
500
+ /**
501
+ * One place a service runs: an Argo CD Application, as the deployer listed
502
+ * it, reduced to what a deploy changes.
503
+ *
504
+ * A list on the catalog rather than a field on `Service`, for two reasons.
505
+ * The fetcher that writes it reads a control plane, not a service's tree, so
506
+ * it does not know which service an Application is; the join is made where
507
+ * the whole estate is known, by the repository and path the Application
508
+ * deploys from against the ones a service says it lives at. And one service
509
+ * stands in several places - staging, production, a second region - and
510
+ * each is its own row.
511
+ *
512
+ * Nothing here moves without a deploy. Health, sync state and the time of
513
+ * the last operation move on their own, and a page regenerated from the
514
+ * tree cannot follow them; they are not kept.
515
+ */
516
+ export interface Deployment {
517
+ /** `<argocd namespace>/<application name>`: unique per control plane. */
518
+ id: string;
519
+ /** The Application's name, as `argocd app get` takes it. */
520
+ name: string;
521
+ /** The Argo CD project it belongs to. */
522
+ project: string;
523
+ /**
524
+ * Where a reader would say it runs: the application's environment label,
525
+ * else the cluster it deploys to. What the page groups by.
526
+ */
527
+ environment: string;
528
+ /** The destination cluster, by its Argo CD name; `in-cluster` for the one Argo CD runs in. */
529
+ cluster: string;
530
+ /** The destination namespace. */
531
+ namespace: string;
532
+ /** The repository it deploys from, spelled the way `Service.repo` spells it. Empty for a chart from a registry. */
533
+ repo: string;
534
+ /** The directory in that repository the manifests are read from, as a reader would type it. Empty for a chart. */
535
+ path: string;
536
+ /** The Helm chart name, when the source is a chart registry rather than a repository. */
537
+ chart?: string;
538
+ /** What the Application tracks: a branch, a tag, a chart version. */
539
+ targetRevision: string;
540
+ /** What is deployed now: the commit, or chart version, the last sync resolved `targetRevision` to. */
541
+ revision: string;
542
+ /** `helm`, `kustomize`, `directory` or `plugin`, as Argo CD says it; empty until it has said. */
543
+ tool: string;
544
+ /** The Application in the Argo CD UI. */
545
+ url: string;
546
+ /**
547
+ * The service it deploys, by id, when the Application's labels say so:
548
+ * `app.kubernetes.io/part-of` and `app.kubernetes.io/name`, the same
549
+ * labels a workload carries. A GitOps repository's Application points at
550
+ * an overlay, not at the service's directory, so the path alone cannot
551
+ * place it; the labels can, and an ApplicationSet stamps them on every
552
+ * Application it makes. Absent when the labels are, and the path decides.
553
+ */
554
+ service?: string;
555
+ /** The container images the deployed resources run, as the deployer summarised them, sorted. */
556
+ images?: string[];
557
+ /**
558
+ * Who said so. `manifest` is a row an extractor read out of a GitOps
559
+ * tree: what should run. `api` is a row a fetcher read off the deployer:
560
+ * what does. `both` is one the merge folded from the two, which is the
561
+ * ordinary case for an estate that keeps both, and the only one that can
562
+ * carry drift. Absent reads as `api`, which is what every row was before
563
+ * there were trees to read.
564
+ */
565
+ basis?: DeploymentBasis;
566
+ /**
567
+ * What the tree says where it and the deployer disagree, set by the merge
568
+ * when both spoke (portolan.0013). A field is present only when the two
569
+ * differ; a row with no drift has no field at all.
570
+ */
571
+ drift?: DeploymentDrift;
572
+ }
573
+
574
+ export type DeploymentBasis = "manifest" | "api" | "both";
575
+
576
+ export const DEPLOYMENT_BASES: readonly DeploymentBasis[] = [
577
+ "manifest",
578
+ "api",
579
+ "both",
580
+ ] as const;
581
+
582
+ /** The GitOps tree's word on the fields where the deployer says otherwise. */
583
+ export interface DeploymentDrift {
584
+ project?: string;
585
+ cluster?: string;
586
+ namespace?: string;
587
+ path?: string;
588
+ targetRevision?: string;
589
+ /** The images the overlay pins, when they are not the ones running. */
590
+ images?: string[];
591
+ }
592
+
593
+ /** The basis a row carries, `api` when it carries none. */
594
+ export function deploymentBasis(deployment: Deployment): DeploymentBasis {
595
+ return deployment.basis ?? "api";
596
+ }
445
597
  export interface Aggregate {
446
598
  id: string;
447
599
  slug: string;
448
600
  name: string;
449
601
  readme: string;
450
- /** Name of the entity that is the aggregate root; must be one of `entities`. */
602
+ /** A source grouping has no confirmed aggregate boundary. Omitted for aggregates. */
603
+ kind?: "model-group";
604
+ /** Name of the root entity; empty only for a model-group. */
451
605
  root: string;
452
606
  entities: Entity[];
453
607
  valueObjects: ValueObject[];
@@ -506,6 +660,15 @@ export interface Operation {
506
660
  * estate can only reach from inside, which is sometimes exactly the point.
507
661
  */
508
662
  exposedBy?: string[];
663
+ /**
664
+ * What the caller hands in: the command's or query's own shape, as the
665
+ * message class declares it - the fields of `CreateCourseCommand`. Absent
666
+ * when the extractor does not read messages; empty is a message that
667
+ * carries nothing, `FindCoursesCounterQuery`.
668
+ */
669
+ fields?: Field[];
670
+ /** Where the handler is, `path:line`, for the reader who wants the code. */
671
+ source?: string;
509
672
  }
510
673
 
511
674
  /**
@@ -656,7 +819,31 @@ export interface Field {
656
819
  ref?: string;
657
820
  /** Protobuf field number; absent for sources whose wire has no field numbers. */
658
821
  number?: number;
822
+ /**
823
+ * The source says the field must be sent: a Protovalidate `required`, a
824
+ * name in a JSON Schema `required` list. Absent means the source does not
825
+ * say, which in proto3 and OpenAPI alike means it may be left out.
826
+ */
827
+ required?: boolean;
828
+ /** What the source says a value must satisfy, in the order it said it. */
829
+ rules?: FieldRule[];
659
830
  } // ref -> defs key
831
+ /**
832
+ * One constraint on a field's value, in the catalog's own vocabulary so a
833
+ * Protovalidate `min_len` and a JSON Schema `minLength` are one rule:
834
+ * `min_len`, `max_len`, `len`, `pattern`, `prefix`, `suffix`, `contains`,
835
+ * `not_contains`, `format`, `gt`, `gte`, `lt`, `lte`, `const`, `in`,
836
+ * `not_in`, `multiple_of`, `min_items`, `max_items`, `unique`, `min_pairs`,
837
+ * `max_pairs`, `defined_only`, `lt_now`, `gt_now`, `cel`. A rule on what a
838
+ * list holds is prefixed `items.`; on a map's keys or values, `keys.` or
839
+ * `values.`. A custom option the catalog has no word for keeps the name the
840
+ * source gave it, `(acme.pii)`, so it is shown rather than lost.
841
+ */
842
+ export interface FieldRule {
843
+ name: string;
844
+ /** The bound as the source wrote it, text so a 64-bit number survives; absent for a bare flag such as `unique`. */
845
+ value?: string;
846
+ }
660
847
  export interface TypeDef {
661
848
  fields: Field[];
662
849
  }
@@ -679,6 +866,7 @@ export type StoreKind =
679
866
  | "mongodb"
680
867
  | "clickhouse"
681
868
  | "s3"
869
+ | "dynamodb"
682
870
  | "other";
683
871
 
684
872
  export const STORE_KINDS: readonly StoreKind[] = [
@@ -689,6 +877,7 @@ export const STORE_KINDS: readonly StoreKind[] = [
689
877
  "mongodb",
690
878
  "clickhouse",
691
879
  "s3",
880
+ "dynamodb",
692
881
  "other",
693
882
  ] as const;
694
883
 
@@ -734,7 +923,7 @@ export interface RedisKeyspace {
734
923
  value?: string;
735
924
  source?: string;
736
925
  /** Aggregate or block whose value this key family holds, when provable. */
737
- persists?: { aggregate?: string; block?: string };
926
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
738
927
  /** Individual client calls, before they are folded into `operations`. */
739
928
  accesses?: RedisAccess[];
740
929
  }
@@ -767,13 +956,14 @@ export const TABLE_ROLES: readonly TableRole[] = [
767
956
  ] as const;
768
957
 
769
958
  export interface Table {
959
+ evidence?: RelationEvidence[];
770
960
  id: string; // "<store id>.<table>"
771
961
  name: string;
772
962
  doc?: string;
773
963
  columns: Column[];
774
964
  indexes?: TableIndex[];
775
965
  /** The domain object this table holds: an aggregate id, and optionally a block id. */
776
- persists?: { aggregate?: string; block?: string };
966
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
777
967
  role?: TableRole;
778
968
  /** Source-backed repository methods that read or write this table. */
779
969
  accesses?: TableAccess[];
@@ -853,7 +1043,7 @@ export interface View {
853
1043
  /** The SELECT, as the migration declares it. Shown, never parsed. */
854
1044
  definition?: string;
855
1045
  /** The domain object this view presents, when it presents exactly one. */
856
- persists?: { aggregate?: string; block?: string };
1046
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
857
1047
  /** Migration or model file, as a reader would open it. */
858
1048
  source?: string;
859
1049
  }
@@ -886,6 +1076,47 @@ export interface Flow {
886
1076
  owner: string;
887
1077
  participants: Participant[]; // order is significant - it is the lane order
888
1078
  steps: FlowNode[];
1079
+ /**
1080
+ * Recordings of this flow running: one per trace a verifier kept, each
1081
+ * naming the steps it showed with what the spans said about them. Examples
1082
+ * of data, not evidence - the evidence is the status on the step.
1083
+ */
1084
+ examples?: FlowExample[];
1085
+ }
1086
+ /** One recorded run of a flow, read from one trace. */
1087
+ export interface FlowExample {
1088
+ /** "<recording>#<trace id>": unique within the flow, stable across runs. */
1089
+ id: string;
1090
+ /**
1091
+ * The file the trace was read from, relative to the verify step's input
1092
+ * root - where an uploaded recording is kept.
1093
+ */
1094
+ recording: string;
1095
+ traceId: string;
1096
+ /** When the root span started, RFC 3339 UTC; absent when the recording has no clock. */
1097
+ recordedAt?: string;
1098
+ /** Root start to the last end the trace shows. */
1099
+ durationMs: number;
1100
+ /** The steps this trace showed, in the order it showed them. */
1101
+ steps: ExampleStep[];
1102
+ }
1103
+ /**
1104
+ * What one span said about one step. Only an allowlist of attributes is
1105
+ * carried: names, verbs, routes, status codes - never a query text, a header
1106
+ * or a path with an id in it.
1107
+ */
1108
+ export interface ExampleStep {
1109
+ step: string;
1110
+ label?: string;
1111
+ durationMs: number;
1112
+ attributes?: Record<string, string>;
1113
+ }
1114
+ /**
1115
+ * How many recordings showed a step. On a declared step it accompanies the
1116
+ * raised status; on a step no source declares it is why the step is there.
1117
+ */
1118
+ export interface StepSeen {
1119
+ traces: number;
889
1120
  }
890
1121
  export interface FlowTrigger {
891
1122
  kind:
@@ -908,7 +1139,18 @@ export interface Participant {
908
1139
  label?: string;
909
1140
  }
910
1141
  export type FlowNode = Step | Parallel | Alt | Loop;
1142
+ /** Source facts used to derive a relationship; not a runtime trace. */
1143
+ export interface RelationEvidence {
1144
+ kind: "call-site" | "function" | "binding" | "contract" | "resolution" | "unresolved";
1145
+ rule: string;
1146
+ source?: string;
1147
+ symbol?: string;
1148
+ candidates?: string[];
1149
+ }
1150
+
911
1151
  export interface Step {
1152
+ evidence?: RelationEvidence[];
1153
+ destination?: HTTPDestination;
912
1154
  type: "step";
913
1155
  id: string;
914
1156
  from: string;
@@ -931,6 +1173,8 @@ export interface Step {
931
1173
  handoff?: FlowHandoff;
932
1174
  /** Repository call resolved to a concrete store operation after merge. */
933
1175
  storeAccess?: FlowStoreAccess;
1176
+ /** Recordings that showed this hop; set by a verifier. */
1177
+ seen?: StepSeen;
934
1178
  }
935
1179
  export interface HTTPResponse {
936
1180
  status?: number;
@@ -986,6 +1230,12 @@ export interface AltBranch {
986
1230
  steps: FlowNode[];
987
1231
  /** True when the flow stops here instead of continuing past the alt. */
988
1232
  terminal?: boolean;
1233
+ /**
1234
+ * Recordings that went this way, on a frame a verifier wrote where the
1235
+ * recordings parted. A branch with no steps and a count is the recordings
1236
+ * that went no further.
1237
+ */
1238
+ seen?: StepSeen;
989
1239
  }
990
1240
  export interface Loop {
991
1241
  type: "loop";
@@ -1229,6 +1479,44 @@ export function allRepos(catalog: Catalog): RepoPin[] {
1229
1479
  return catalog.repos ?? [];
1230
1480
  }
1231
1481
 
1482
+ /** Every place a service runs. Absent means none, exactly as with repos. */
1483
+ export function allDeployments(catalog: Catalog): Deployment[] {
1484
+ return catalog.deployments ?? [];
1485
+ }
1486
+
1487
+ /**
1488
+ * Whether a deployment is of this service. The labels decide when the
1489
+ * Application carries them: `service` is the id, and an Application
1490
+ * labelled for one service is not another's however its path reads. Without
1491
+ * labels, the path: the same repository, and the manifests read from inside
1492
+ * the service's directory - or from anywhere in the repository when the
1493
+ * service is the whole of it. An Application that matches nothing is a fact
1494
+ * for the Problems page rather than for a guess.
1495
+ */
1496
+ export function deploys(deployment: Deployment, service: Service): boolean {
1497
+ if (deployment.service) return deployment.service === service.id;
1498
+ if (!deployment.repo || deployment.repo !== service.repo) return false;
1499
+ const root = service.path.replace(/^\/+|\/+$/g, "");
1500
+ if (!root) return true;
1501
+ return deployment.path === root || deployment.path.startsWith(`${root}/`);
1502
+ }
1503
+
1504
+ /**
1505
+ * The environment a reader would say a deployment stands in: the one the
1506
+ * deployer named, else the cluster, else the one word that says neither was
1507
+ * said. Every picture and filter groups by this, so it is decided once.
1508
+ */
1509
+ export function environmentOf(deployment: Deployment): string {
1510
+ return deployment.environment || deployment.cluster || "unplaced";
1511
+ }
1512
+
1513
+ /** Where this service runs, in catalog order: the snapshot's, which is by id. */
1514
+ export function deploymentsOf(catalog: Catalog, service: Service): Deployment[] {
1515
+ return allDeployments(catalog).filter((deployment) =>
1516
+ deploys(deployment, service),
1517
+ );
1518
+ }
1519
+
1232
1520
  /** Who to ask about a service, without the caller having to know the field is optional. */
1233
1521
  export function ownersOf(service: Service): string[] {
1234
1522
  return service.owners ?? [];