@shortlink-org/portolan 0.2.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,44 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "properties": {
5
+ "repo": {
6
+ "type": "string",
7
+ "description": "The repository this tree is, spelled the way `Service.repo` spells it. A git generator pointing at another repository cannot be walked and is passed over with a warning; left out, every git generator is taken to mean this tree.",
8
+ "examples": ["github.com/acme/gitops"]
9
+ },
10
+ "paths": {
11
+ "type": "array",
12
+ "items": { "type": "string" },
13
+ "description": "Directories under the input root that hold the Applications and ApplicationSets, so overlays and charts beside them are not read for Argo CD objects. Left out, the whole root is walked.",
14
+ "examples": [["bootstrap", "appsets"]]
15
+ },
16
+ "environmentLabel": {
17
+ "type": "string",
18
+ "default": "env",
19
+ "description": "The Application label that names the environment. An Application without it is placed by the cluster it deploys to."
20
+ },
21
+ "labels": {
22
+ "type": "object",
23
+ "additionalProperties": false,
24
+ "description": "Which Application labels name the service it deploys. The defaults are the Kubernetes recommended labels, the same ones fetch-argocd and fetch-k8s read.",
25
+ "properties": {
26
+ "context": {
27
+ "type": "string",
28
+ "description": "The label whose value is the bounded context's slug.",
29
+ "default": "app.kubernetes.io/part-of"
30
+ },
31
+ "service": {
32
+ "type": "string",
33
+ "description": "The label whose value is the service's slug.",
34
+ "default": "app.kubernetes.io/name"
35
+ }
36
+ }
37
+ },
38
+ "out": {
39
+ "type": "string",
40
+ "description": "Name of the fragment file, inside the step's output directory.",
41
+ "default": "argocd.json"
42
+ }
43
+ }
44
+ }
@@ -155,8 +155,6 @@ def extract(input_: Input, opts: Options, b: Builder, cwd: str = "") -> None:
155
155
  flows.sort(key=lambda f: f["slug"])
156
156
 
157
157
  fragment: Dict[str, Any] = {
158
- "generatedAt": input_.generated_at,
159
- "commit": input_.commit,
160
158
  "contexts": [
161
159
  {
162
160
  "id": context,
@@ -27,7 +27,7 @@ ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
27
27
  def run(fixture, options):
28
28
  b = Builder()
29
29
  extract(
30
- Input(root=os.path.relpath(os.path.join(HERE, "testdata", fixture), ROOT), commit="abc1234", generated_at="2026-09-05T00:00:00Z"),
30
+ Input(root=os.path.relpath(os.path.join(HERE, "testdata", fixture), ROOT)),
31
31
  Options.of(options),
32
32
  b,
33
33
  cwd=ROOT,
@@ -25,6 +25,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
25
25
  DESCRIPTOR = {
26
26
  "name": "extract-celery",
27
27
  "summary": "Reads Celery task declarations, the calls that enqueue them and the routes that place them into work queues and source-backed job flows.",
28
+ "category": "messaging",
28
29
  "phases": ["extract"],
29
30
  }
30
31
 
@@ -0,0 +1,213 @@
1
+ # extract-csharp-ddd
2
+
3
+ A C# tree laid out by module and layer in, a catalog fragment out - three
4
+ kinds, when the API host proves an HTTP contract and the database project a
5
+ schema. The .NET twin of `extract-php-ddd`: like it, this reads a layout
6
+ rather than a framework, and the layout is the claim. Nothing is annotated
7
+ for the catalog; the directory a class sits in and the base it extends say
8
+ what it is.
9
+
10
+ The layout is the one [kgrzybek's modular-monolith-with-ddd](https://github.com/kgrzybek/modular-monolith-with-ddd)
11
+ made common in .NET - `src/Modules/<Module>/{Domain,Application,Infrastructure,IntegrationEvents}`
12
+ with one API host under `src/API` and one SQL Server database project under
13
+ `src/Database` - and it is that project the site ships as a profile, read by
14
+ this extractor alone.
15
+
16
+ Written in C# and run as a process plugin, `dotnet plugins/extract-csharp-ddd/bin/portolan-extract-csharp-ddd.dll`,
17
+ built by `npm run plugins:build` with the .NET SDK (8 or later). The parser
18
+ is Roslyn, the compiler's own, and the one dependency; every `.cs` under the
19
+ root goes into one `Compilation`, so a partial class is one type, a base
20
+ chain is followed across files and modules, and the type of `new X(...)` or
21
+ of a variable is a fact rather than a guess. **The application is never
22
+ built or run, and none of its packages is restored.** What comes from NuGet
23
+ - MediatR's `INotificationHandler`, EF's `IEntityTypeConfiguration`,
24
+ ASP.NET's `ControllerBase` - is an error type with a name, and every rule
25
+ that touches one goes by the name as written on the base list. Only the
26
+ runtime's own assemblies are referenced, so that `Guid`, `string` and
27
+ `List<T>` read as themselves.
28
+
29
+ ## The layout it reads
30
+
31
+ ```
32
+ src/Modules/<Module>/Domain/<Aggregate>/ the model: root, entities, value objects, events, ports
33
+ src/Modules/<Module>/Application/<Group>/ the use cases: commands, queries, their handlers, notification handlers
34
+ src/Modules/<Module>/Infrastructure/ the adapters: repositories, EF configurations, the bus subscriptions
35
+ src/Modules/<Module>/IntegrationEvents/ what the module tells the other modules
36
+ src/API/**/Modules/<Module>/*Controller.cs the HTTP edge of that module
37
+ src/Database/**/Structure/<schema>/Tables/ the tables, one file each, T-SQL
38
+ src/Database/**/Structure/<schema>/Views/ the views over them
39
+ src/BuildingBlocks/ the bases: Entity, ValueObject, DomainEventBase, IntegrationEvent
40
+ ```
41
+
42
+ `modules`, `api` and `database` in the options rename the three roots. A
43
+ directory named `Tests`, or ending in `Tests`, is not read; neither is `bin/`
44
+ or `obj/`.
45
+
46
+ ## What becomes what
47
+
48
+ **Context.** A directory under `src/Modules` with `Domain` or `Application`
49
+ under it, with its slug as id - `user-access` for `UserAccess`.
50
+ `classification` in the options applies to every module the tree declares;
51
+ nothing in the tree says which is core.
52
+
53
+ **Service.** One per module, `<module>.module` - `meetings.module` - because
54
+ a module here has its own schema, its own outbox, its own composition root
55
+ and its own slice of the API. There is one deployable, and the catalog says
56
+ so where it matters: a call from one module to another is drawn as a `call`
57
+ step with a note that it is in process. The controllers filed under the
58
+ module's directory of the API host are its HTTP interfaces, one per
59
+ controller, `meetings.module.meetings`.
60
+
61
+ **Aggregate.** A directory under `Domain/` whose own files declare a root: a
62
+ class implementing `IAggregateRoot` or extending a class named
63
+ `AggregateRoot` (the event-sourced base Payments keeps in its `SeedWork`).
64
+ Subdirectories with no root of their own fold in - `Events/`, `Rules/` - and
65
+ one with a root is an aggregate in its own right, `Members/MemberSubscriptions`.
66
+ `SharedKernel`, `SeedWork`, `Shared`, `Rules`, `Events` and `Exceptions` at
67
+ the top of `Domain/` are not aggregates. A directory with types and no root
68
+ is a group the application layer may claim as a `model-group`; unclaimed, it
69
+ is reported.
70
+
71
+ The root and every class extending `Entity` are the entities, the root first.
72
+ Their fields are the public properties inherited from a base that is not the
73
+ framework's, then their own members in source order, a private `_title`
74
+ read as `title`, the `_domainEvents` list left out. A value object is a
75
+ class extending `ValueObject`, `TypedIdValueBase` or `AggregateId<T>`, with
76
+ its public properties read up the base chain, so `MeetingId` has the base's
77
+ `value: Guid`; one with no fields is reported and not written. A C# `enum` in
78
+ the directory is a closed set with its members, `[Obsolete]` carried as
79
+ deprecated. Rules, exceptions, domain services and `I*Context` interfaces are
80
+ not shape and are skipped.
81
+
82
+ **Domain event.** A class extending `DomainEventBase` or implementing
83
+ `IDomainEvent`, in the aggregate's directory. Its fields are its own public
84
+ properties - `Id` and `OccurredOn` are the envelope. It has no wire: a
85
+ domain event here is a MediatR notification in the same transaction, and a
86
+ notification replayed from the outbox after it. Its consumers are the
87
+ module's `INotificationHandler<TheEvent>` classes, and its
88
+ `INotificationHandler<TheNotification>` classes where the notification
89
+ extends `DomainNotificationBase<TheEvent>`, each `declared` with the
90
+ handler's name. The id keeps the class name whole,
91
+ `meetings.module.meetings.MeetingCreatedDomainEvent`, because the
92
+ integration event that follows it is a different fact with the same stem.
93
+
94
+ **Integration event.** A class extending `IntegrationEvent` under the
95
+ module's `IntegrationEvents/`. Its publisher is the module whose application
96
+ layer calls `IEventsBus.Publish(new X(...))`, and its aggregate is the one
97
+ whose domain event the publishing handler's notification wraps; an event
98
+ nobody publishes is reported and filed under the owning module's
99
+ `integration-events` group. Its wire is the class name on its own channel,
100
+ `<bus>.<Event>` with `bus` from the options, `integration-events` by
101
+ default, because the code names the class `InMemoryEventBus` and nothing
102
+ else. Its consumers are the modules whose
103
+ infrastructure says `SubscribeToIntegrationEvent<X>` or
104
+ `new IntegrationEventGenericHandler<X>()`, each with the
105
+ `INotificationHandler<X>` that hears it - `declared` when both are there,
106
+ `unresolved` and reported when a module subscribes without a handler or
107
+ handles without subscribing.
108
+
109
+ **Channel.** The bus has no named channels - `InMemoryEventBus` routes by
110
+ type and a module subscribes to a type - so each integration event is a
111
+ channel of its own, `<bus>.<Event>`, of kind `event`: the module that
112
+ publishes it sends on it, the modules that subscribe receive. Every module
113
+ whose application layer calls `ICommandsScheduler.EnqueueAsync(new C(...))`
114
+ has a `job` channel, `<module>.internal-commands`: the command written to
115
+ its `InternalCommands` table in the same transaction, read by the
116
+ ProcessInternalCommands job and handed to its handler. A command enqueued
117
+ that no handler in the module takes is reported.
118
+
119
+ **Operation.** A class under `Application/` whose base list says
120
+ `ICommandHandler<C>` or `ICommandHandler<C, R>` or `IQueryHandler<Q, R>`; the
121
+ message names the operation, `create-meeting` for `CreateMeetingCommand`.
122
+ Its fields are the message's widest constructor's parameters, or its
123
+ settable properties when it has none; its doc the handler's `<summary>`, or
124
+ the message's; its source the handler's `Handle`. The aggregate is the
125
+ domain directory named like the first directory under `Application/`
126
+ (`Application/Meetings/CreateMeeting/` is `Domain/Meetings`), and a group
127
+ with no such directory - `Countries`, `Authentication` - is a model-group
128
+ named after it. A message extending `InternalCommandBase` is reached from
129
+ the job channel; one implementing `IRecurringCommand` runs on a Quartz
130
+ schedule. A handler under `Infrastructure/` is plumbing - `ProcessOutboxCommandHandler`
131
+ - and is not read. `exposedBy` names the actions of the module's own
132
+ controllers that new the message up; an action in another module's
133
+ controller is a call across contexts and lives in the flow.
134
+
135
+ **Store.** One per schema of the database project, owned by the module
136
+ whose EF configurations `ToTable("X", "schema")` into it, else the module
137
+ named like it, else the module that writes it; a read owns nothing, and a
138
+ schema with no owner - `app`, written by the building blocks - is reported
139
+ and not read. The module's home schema is its `db`, `meetings.module.db`;
140
+ a second schema keeps its name. `storeKind` in the options says what the
141
+ schemas live in, `other` for SQL Server. A table is read from its
142
+ `CREATE TABLE`: columns with type and nullability, the primary key from the
143
+ constraint or the column, foreign keys, and the indexes created beside it.
144
+ A view is read from its `CREATE VIEW`: the select list's names, each typed
145
+ from the column it selects when the alias resolves, the tables it reads,
146
+ and the SELECT itself as the definition.
147
+
148
+ An `IEntityTypeConfiguration<T>` in the module's infrastructure says which
149
+ table holds T: the `ToTable` in `Configure` is the table, `Property<X>("_f").HasColumnName("C")`
150
+ and `Property(x => x.P)` map its columns to T's fields, an `OwnsOne` maps
151
+ the value object's parts to the owner's field, and an `OwnsMany` with a
152
+ `ToTable` of its own is the child's table. The table `persists` the block
153
+ and takes the role `aggregate-root` or `child`; a table nobody maps is
154
+ `outbox` when it is the outbox, `projection` when the application
155
+ layer writes it with SQL, `lookup` when it is only ever read, `other`
156
+ otherwise. A `Messages` or `Streams` table is where an event-sourced
157
+ module's `IAggregateStore` keeps its streams.
158
+
159
+ Accesses come from two places. A class in the infrastructure implementing an
160
+ `I*Repository` port from the domain, or `IAggregateStore`, reads and writes
161
+ the table that holds the port's root - `write MeetingRepository.AddAsync` -
162
+ by the method's name: `Add`, `Save`, `Update`, `Append` write, `Remove` and
163
+ `Delete` delete, the rest read. Every string in a method of the application
164
+ or infrastructure layer is read as SQL: `FROM`, `JOIN`, `INSERT INTO`,
165
+ `UPDATE` and `DELETE FROM` against a schema-qualified name are accesses by
166
+ that method; a view read is an access on the tables the view reads; a name
167
+ the database project does not declare is reported - which is how the
168
+ project's `v_Countriess` was found.
169
+
170
+ **Flow.** One per controller action: the request in, the command or query
171
+ the action news up, then what the handler does, followed through the
172
+ classes it holds and the domain methods it calls, by symbol. A call on a
173
+ port is a step to the store; a Dapper query a step to the tables its SQL
174
+ names; `AddDomainEvent(...)` an event raised in process, drawn as a
175
+ self-message with the event's id; `IEventsBus.Publish(...)` an event on the
176
+ bus with a `message` handoff; `ICommandsScheduler.EnqueueAsync(...)` a `job`
177
+ handoff to the module's internal queue; a command handed to another module's
178
+ facade a `call` across the boundary, followed there. One flow per
179
+ notification handler of a domain event, from the event in; one per
180
+ subscription a module handles, from the bus in; one per internal command,
181
+ from the queue in; one per recurring command, on its schedule. Each flow
182
+ carries the trigger it was read from: `http`, `event`, `message`, `job` or
183
+ `scheduled`.
184
+
185
+ **HTTP.** One OpenAPI 3.1 document per module with controllers,
186
+ `openapi.<module>.yaml` (`openapiOut` in the options), one operation per
187
+ action: the path from `[Route]` and `[HttpGet("...")]` with `[controller]`
188
+ filled in, the `{parameters}` typed from the action's, the `[FromBody]`
189
+ class as the request schema one level deep, `[ProducesResponseType]` as the
190
+ responses, `[HasPermission]` as `x-portolan-permission`, the action's
191
+ `<summary>` or its name as the summary.
192
+
193
+ ## Options
194
+
195
+ See `options.schema.json`. `modules`, `api` and `database` default to
196
+ `src/Modules`, `src/API` and `src/Database`; `bus` to `integration-events`;
197
+ `storeKind` to `other`; `repo` and `classification` to nothing.
198
+
199
+ ## Tests
200
+
201
+ ```bash
202
+ dotnet run --project plugins/extract-csharp-ddd/test
203
+ ```
204
+
205
+ `testdata/mymeetings` is a small tree in the layout: three modules, one of
206
+ them event-sourced, a module publishing an integration event two others
207
+ hear, an internal command enqueued from a subscription, a recurring command,
208
+ an EF configuration with owned types and a child table, views, and a
209
+ controller filed under a module the tree does not have. `expected.json`,
210
+ `expected-stores.json` and the `openapi.*.yaml` beside it are what the
211
+ extractor writes for it, and `expected-warnings.txt` what it says on the
212
+ way; `UPDATE_GOLDEN=1` writes them again after a deliberate change, and the
213
+ diff is the review.
@@ -0,0 +1,55 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "properties": {
5
+ "modules": {
6
+ "type": "string",
7
+ "description": "The directory the modules are in, relative to the input root: one directory per module, `Domain`, `Application`, `Infrastructure` and `IntegrationEvents` under it. Each module is a bounded context with one service.",
8
+ "default": "src/Modules"
9
+ },
10
+ "api": {
11
+ "type": "string",
12
+ "description": "The directory the HTTP host is in, relative to the input root. Controllers under a `Modules/<Module>` directory beneath it are that module's HTTP edge.",
13
+ "default": "src/API"
14
+ },
15
+ "database": {
16
+ "type": "string",
17
+ "description": "The directory the database project is in, relative to the input root: `Structure/<schema>/Tables/*.sql` and `Structure/<schema>/Views/*.sql` beneath it, one schema per module.",
18
+ "default": "src/Database"
19
+ },
20
+ "repo": {
21
+ "type": "string",
22
+ "description": "Where the source lives, as `host/owner/name`. Nothing in a .NET tree says, so the manifest does.",
23
+ "examples": ["github.com/kgrzybek/modular-monolith-with-ddd"]
24
+ },
25
+ "bus": {
26
+ "type": "string",
27
+ "description": "The address of the bus integration events cross modules on. The code names the class, `InMemoryEventBus`, and nothing else, so the manifest says.",
28
+ "default": "integration-events"
29
+ },
30
+ "storeKind": {
31
+ "enum": ["postgres", "mysql", "sqlite", "other"],
32
+ "description": "What the schemas live in. SQL Server is `other`.",
33
+ "default": "other"
34
+ },
35
+ "classification": {
36
+ "enum": ["core", "supporting", "generic"],
37
+ "description": "Whether the modules are core to the business, support it, or are generic and could be bought. One value for every module the tree declares."
38
+ },
39
+ "out": {
40
+ "type": "string",
41
+ "description": "Name of the fragment file, inside the step's output directory.",
42
+ "default": "domain.json"
43
+ },
44
+ "openapiOut": {
45
+ "type": "string",
46
+ "description": "Name of the inferred OpenAPI 3.1 document, inside the step's output directory, one per module with controllers; `{service}` is replaced with the module's slug.",
47
+ "default": "openapi.{service}.yaml"
48
+ },
49
+ "storesOut": {
50
+ "type": "string",
51
+ "description": "Name of the store fragment, inside the step's output directory. Written when the database project declares a schema a module maps to.",
52
+ "default": "stores.json"
53
+ }
54
+ }
55
+ }
@@ -39,21 +39,41 @@ resolves them without a type checker.
39
39
 
40
40
  ## What becomes what
41
41
 
42
- **Aggregate.** One per application. The root is the model named after the
43
- application `invoices` holds `Invoice` or the only model there is, or the
44
- one `aggregates` names in the manifest; an application with several models and
45
- no such name is reported and skipped, because guessing which of them the others
46
- hang off is how an aggregate boundary gets drawn wrong and stays wrong. The
47
- aggregate is named and slugged after the root, so a Django `invoices` and a Go
48
- `invoice` package land on the same id. Every concrete model of the application
49
- is an entity, the root included and first; abstract models are not. The
50
- readme is `README.md` in the application, or the root's docstring.
42
+ **Models and aggregates.** Every concrete, non-proxy model is visible. When
43
+ an application has an explicitly configured root, a model named after the
44
+ application (`invoices` `Invoice`), or just one concrete model, it retains
45
+ its existing aggregate representation and stable root-based id.
46
+
47
+ Otherwise the application is emitted as a `kind: "model-group"` entry with
48
+ an empty `root` and an id ending in `models-<application-package>`. All of its
49
+ concrete models appear once, alongside the application's value objects,
50
+ operations and events. This is a source grouping, not a claim that the models
51
+ share a transactional boundary. Its tables link to the model blocks without
52
+ being labelled aggregate roots or children. No root-selection warning is
53
+ emitted, and no configuration is required to browse the models.
54
+
55
+ `options.aggregates` remains an optional refinement: selecting a root replaces
56
+ the source group with an aggregate. A configured root that no longer exists
57
+ produces a warning with concrete candidates, while retaining every model in
58
+ the group. Settings can help repair that configuration and save the choice to
59
+ the matching extraction step. Regenerate to apply it; other options are kept.
51
60
 
52
61
  **Field.** Each model attribute assigned a field, with the type as written:
53
62
  `CharField`, `DateTimeField`, and a relation as `ForeignKey[Invoice]`. The doc
54
63
  is the field's own `help_text`, which is the one place a Django model already
55
- writes down what a column means. Value objects are the frozen dataclasses in
56
- `values.py`, their fields the annotations as written.
64
+ writes down what a column means. What the field call says a value must
65
+ satisfy is read into the catalog's own words (portolan.0015): `max_length`
66
+ is `max_len`, `unique=True` is `unique`, `choices` is `in` with the stored
67
+ values — read off a `Choices` class, a literal list of pairs or a module-level
68
+ constant, and left out when they come from a call — an `EmailField`,
69
+ `URLField` or `UUIDField` is `format`, a `PositiveIntegerField` is `gte 0`,
70
+ and `MinValueValidator`, `MaxValueValidator`, `MinLengthValidator`,
71
+ `MaxLengthValidator` and `RegexValidator` are the bounds they were given. The
72
+ model is the source of truth for `required`: a field must be given unless
73
+ the model fills it (`default`, `auto_now`, an auto id) or excuses it (`blank`,
74
+ `null`); what a serializer adds is that layer's word and is not read here.
75
+ Value objects are the frozen dataclasses in `values.py`, their fields the
76
+ annotations as written.
57
77
 
58
78
  **Event.** `events.py`, in either of the two ways a Django project says it. A
59
79
  dataclass with `name = "billing.InvoiceIssued"` is an event, its payload the
@@ -162,9 +182,24 @@ observable effects. Traversal is bounded and cycles are cut by symbol, so a
162
182
  recursive helper cannot make extraction recursive. ORM models from routed
163
183
  applications remain visible here even when the application has no unambiguous
164
184
  aggregate root. A URLConf may directly mount an arbitrarily named method of a
165
- plain class, such as `Planet.fetch`; that is still an HTTP flow root. When no
166
- decorator or conventional handler name proves one verb, the flow is retained
167
- but the route is omitted from inferred OpenAPI rather than guessed. An
185
+ plain class, such as `Planet.fetch`, or a plain function; that is still an
186
+ HTTP flow root, and its verb is read off what the code declares, in the order
187
+ a reviewer would trust it: a decorator on the handler (`@action(methods=…)`,
188
+ `@api_view`, `@require_http_methods([…])`, `@require_GET`, `@require_POST`,
189
+ `@require_safe`, also through `method_decorator`), the same decorators on the
190
+ class (`@method_decorator(…, name="dispatch")`), the class's
191
+ `http_method_names` (less `HEAD`, `OPTIONS` and `TRACE`, which every route
192
+ answers), a branch on `request.method` in the handler body, and last a project
193
+ wrapper — a decorator or a function the handler hands `request` to — whose own
194
+ body does one of those, followed a bounded number of levels deep. The first
195
+ tier that speaks decides; a declaration listing several verbs makes one
196
+ endpoint per verb, `planet_status` and `planet_status_patch`. When none of
197
+ them speaks, the verb is not guessed: the route stays in `provides` with an
198
+ empty `http.method`, the inferred OpenAPI document keeps the path as an item
199
+ with no operations and `x-portolan-verb: unknown`, the flow is retained, and
200
+ a diagnostic names the route. The merge never matches an outbound call
201
+ against a route whose verb is unknown, so the link waits for a declaration
202
+ rather than being confirmed by the path alone. An
168
203
  inherited DRF generic action has no local handler body, so
169
204
  its framework behaviour is reconstructed instead: list/retrieve read the
170
205
  model, create/update validate through the selected serializer and persist it,
@@ -267,8 +302,7 @@ The primary store is inferred from `DATABASES["default"]` when that setting is
267
302
  statically readable; `store` remains the override for settings assembled at
268
303
  runtime or for a catalog that needs a different stable slug. Every concrete,
269
304
  non-proxy model becomes a table even when its application has several possible
270
- aggregate roots: ambiguity limits the domain model, not the persistence
271
- schema. Fields from abstract model bases are copied into those tables
305
+ aggregate roots: both the model group and persistence schema stay visible. Fields from abstract model bases are copied into those tables
272
306
  regardless of which model module sorts first. `DEFAULT_AUTO_FIELD` supplies
273
307
  implicit primary-key types. PostgreSQL `ArrayField` nesting and
274
308
  `MultiSelectField` storage are rendered as their database types; `to_field`
@@ -323,9 +357,8 @@ Everything else means what it means for `extract-ts`.
323
357
 
324
358
  These cases do not become facts in the fragment:
325
359
 
326
- - a domain aggregate for an application with no models, or with several and no
327
- root among them named after it (its statically resolvable HTTP routes are
328
- still included);
360
+ - a domain aggregate for an application with no models (its statically
361
+ resolvable HTTP routes are still included);
329
362
  - a model that declares no fields;
330
363
  - an events module holding a class with no wire name, and a signal with no
331
364
  payload;
@@ -0,0 +1,47 @@
1
+ """The `Choices` classes a model keeps, read as the values the column holds.
2
+
3
+ `class Status(models.TextChoices): DRAFT = "draft", "Draft"` says two things:
4
+ the states a lifecycle moves between, and the values a `status` field may
5
+ take. Both readers want the same table, member name to stored value, and
6
+ neither wants the label.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from typing import Dict
13
+
14
+ from source import assigned, bases, inner_class
15
+
16
+
17
+ def is_choices(node: ast.AST) -> bool:
18
+ return isinstance(node, ast.ClassDef) and any(b.split(".")[-1].endswith("Choices") for b in bases(node))
19
+
20
+
21
+ def choices_of(model, name: str) -> Dict[str, str]:
22
+ """A Choices class, as member name to the value stored in the column. Looked
23
+ up inside the model first, then among the module's own classes."""
24
+ node = inner_class(model.node, name)
25
+ if node is None:
26
+ for other in model.module.classes():
27
+ if other.name == name:
28
+ node = other
29
+ break
30
+ if node is None or not is_choices(node):
31
+ return {}
32
+ out = {}
33
+ for member, value, _ in assigned(node):
34
+ if isinstance(value, ast.Constant) and not isinstance(value.value, bool):
35
+ out[member] = str(value.value)
36
+ elif isinstance(value, ast.Tuple) and value.elts and isinstance(value.elts[0], ast.Constant):
37
+ out[member] = str(value.elts[0].value)
38
+ return out
39
+
40
+
41
+ def choice_tables(model) -> Dict[str, Dict[str, str]]:
42
+ """Every Choices class in reach of the model, by class name."""
43
+ out = {}
44
+ for node in list(model.node.body) + list(model.module.tree.body):
45
+ if is_choices(node):
46
+ out[node.name] = choices_of(model, node.name)
47
+ return out
@@ -1,21 +1,22 @@
1
1
  """`models.py` read as the model.
2
2
 
3
- A Django application is one aggregate: the root is the model named after the
4
- application, the other models in it are its entities, and the frozen
5
- dataclasses in `values.py` are its value objects. Nothing is annotated for the
6
- catalog - the application is the claim, the same way a directory is the claim
7
- in `extract-ts`.
3
+ A Django application groups its models. When a root is known, the group is
4
+ an aggregate; otherwise it remains a model-group with no asserted boundary.
5
+ Frozen dataclasses in `values.py` are its value objects. Ambiguity never hides
6
+ the concrete models or requires configuration to browse them.
8
7
  """
9
8
 
10
9
  from __future__ import annotations
11
10
 
12
11
  import ast
12
+ import json
13
13
  from dataclasses import dataclass, field as dc_field
14
14
  from typing import Dict, List, Optional
15
15
 
16
16
  import catalog
17
17
  from apps import App
18
18
  from ids import aggregate_id, block_id, pascal, singular, slug
19
+ from rules import rules_of
19
20
  from source import (
20
21
  Module,
21
22
  Project,
@@ -90,7 +91,7 @@ class Aggregate:
90
91
  """One application, and the catalog object being built from it."""
91
92
 
92
93
  app: App
93
- root: ModelDef
94
+ root: Optional[ModelDef]
94
95
  models: List[ModelDef]
95
96
  aggregate: Dict[str, object]
96
97
 
@@ -257,7 +258,7 @@ def root_of(app: App, models: List[ModelDef], named: Dict[str, str], b) -> Optio
257
258
  for m in concrete:
258
259
  if m.name == want:
259
260
  return m
260
- b.warn(app.rel, "aggregates names %s for %s, and no model there is called that" % (want, app.label))
261
+ b.warn(app.rel, "aggregates names %s for %s, and no model there is called that%s" % (want, app.label, aggregate_candidates(app, concrete)))
261
262
  return None
262
263
  for candidate in (pascal(singular(app.label)), pascal(app.label)):
263
264
  for m in concrete:
@@ -268,13 +269,20 @@ def root_of(app: App, models: List[ModelDef], named: Dict[str, str], b) -> Optio
268
269
  if not concrete:
269
270
  b.warn(app.rel, "no models in this application: nothing here to be an aggregate")
270
271
  return None
271
- b.warn(
272
- app.rel,
273
- "no model called %s, and %d models to choose from: name the root in the aggregates option" % (pascal(singular(app.label)), len(concrete)),
274
- )
275
272
  return None
276
273
 
277
274
 
275
+ def aggregate_candidates(app: App, models: List[ModelDef]) -> str:
276
+ """Keep candidate evidence in the warning-only plugin protocol."""
277
+ return "; aggregate candidates: " + json.dumps({
278
+ "app": app.dotted,
279
+ "models": [
280
+ {"name": model.name, "path": model.module.rel, "line": model.node.lineno}
281
+ for model in sorted(models, key=lambda model: (model.name, model.module.rel))
282
+ ],
283
+ }, ensure_ascii=True)
284
+
285
+
278
286
  def read_aggregates(
279
287
  project: Project,
280
288
  applications: List[App],
@@ -289,14 +297,18 @@ def read_aggregates(
289
297
  if models is None:
290
298
  models = read_models(app)
291
299
  root = root_of(app, models, named, b)
292
- if root is None:
293
- continue
294
300
  concrete = [m for m in models if m.concrete]
295
- ordered = [root] + [m for m in concrete if m is not root]
296
- agg_slug = slug(root.name)
301
+ if not concrete:
302
+ continue
303
+ ordered = [root] + [m for m in concrete if m is not root] if root else concrete
304
+ # An application is a source grouping, not proof of a transactional
305
+ # boundary. Keep every model visible without inventing such a boundary.
306
+ agg_slug = slug(root.name) if root else "models-" + slug(app.dotted.replace(".", "-"))
297
307
  agg_id = aggregate_id(svc_id, agg_slug)
298
- readme = app.readme or (ast.get_docstring(root.node, clean=True) or "").strip()
299
- obj = catalog.aggregate(agg_id, agg_slug, root.name, readme, root.name)
308
+ readme = app.readme or ((ast.get_docstring(root.node, clean=True) or "").strip() if root else "")
309
+ obj = catalog.aggregate(agg_id, agg_slug, root.name if root else app.dotted, readme, root.name if root else "")
310
+ if root is None:
311
+ obj["kind"] = "model-group"
300
312
  for model in ordered:
301
313
  obj["entities"].append(
302
314
  catalog.block(
@@ -304,7 +316,7 @@ def read_aggregates(
304
316
  slug(model.name),
305
317
  model.name,
306
318
  doc(model.node),
307
- [catalog.field(f.name, f.type(), f.help()) for f in model.fields],
319
+ [catalog.field(f.name, f.type(), f.help(), **rules_of(f, model)) for f in model.fields],
308
320
  )
309
321
  )
310
322
  for module, node in value_objects(app):