archsight 0.4.1 → 0.4.3

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/docs/diagram.md +64 -0
  3. data/docs/modeling.md +33 -10
  4. data/lib/archsight/cli.rb +2 -1
  5. data/lib/archsight/database.rb +4 -3
  6. data/lib/archsight/diagram/cli.rb +11 -1
  7. data/lib/archsight/diagram/errors.rb +1 -0
  8. data/lib/archsight/diagram/includes.rb +168 -0
  9. data/lib/archsight/diagram/parser/ast.rb +5 -0
  10. data/lib/archsight/diagram/parser/table_layout.rb +2 -0
  11. data/lib/archsight/diagram/parser.rb +58 -16
  12. data/lib/archsight/diagram.rb +20 -4
  13. data/lib/archsight/export/confluence/storage.rb +6 -1
  14. data/lib/archsight/helpers/diagram_blocks.rb +34 -19
  15. data/lib/archsight/helpers/diagram_includes.rb +67 -0
  16. data/lib/archsight/helpers.rb +1 -0
  17. data/lib/archsight/linter/structure.rb +4 -1
  18. data/lib/archsight/linter.rb +10 -2
  19. data/lib/archsight/references.rb +4 -3
  20. data/lib/archsight/resources/application_component.rb +18 -0
  21. data/lib/archsight/resources/application_service.rb +9 -0
  22. data/lib/archsight/resources/technology_device.rb +56 -4
  23. data/lib/archsight/resources/technology_service.rb +16 -0
  24. data/lib/archsight/version.rb +1 -1
  25. data/lib/archsight/web/api/json_helpers.rb +3 -2
  26. data/lib/archsight/web/api/openapi/spec.yaml +9 -2
  27. data/lib/archsight/web/api/page_helpers.rb +1 -1
  28. data/lib/archsight/web/api/routes.rb +4 -3
  29. data/lib/archsight/web/application.rb +2 -1
  30. data/lib/archsight/web/public/vue/{DocPage-C2ubTXH7.js → DocPage-L0GzDFiV.js} +1 -1
  31. data/lib/archsight/web/public/vue/{EditorPage-Dso6PLMn.js → EditorPage-B3iNbh1u.js} +1 -1
  32. data/lib/archsight/web/public/vue/{ErrorPage-DnZ6YJLi.js → ErrorPage-Lro5vwhy.js} +1 -1
  33. data/lib/archsight/web/public/vue/{GraphView-CTl03Yre.js → GraphView-BRKwxa--.js} +1 -1
  34. data/lib/archsight/web/public/vue/{HomePage-C339nihQ.js → HomePage-DKgI1DXr.js} +2 -2
  35. data/lib/archsight/web/public/vue/{InstanceRouter-ZaCcjlIX.css → InstanceRouter-CJSkGdBG.css} +1 -1
  36. data/lib/archsight/web/public/vue/InstanceRouter-CX6qxVTm.js +1 -0
  37. data/lib/archsight/web/public/vue/{KindList-C0oaM3CF.js → KindList-C556gf23.js} +1 -1
  38. data/lib/archsight/web/public/vue/{PageView-OZB6G9Wu.js → PageView-DAqHjL1x.js} +1 -1
  39. data/lib/archsight/web/public/vue/{QueryError-C2xLkCEm.js → QueryError-DvspMrsh.js} +1 -1
  40. data/lib/archsight/web/public/vue/{ResourceList-DooWFwQP.js → ResourceList-BZOYa68X.js} +1 -1
  41. data/lib/archsight/web/public/vue/{SearchResults-BK-HjFnv.js → SearchResults-DaaOPG3t.js} +1 -1
  42. data/lib/archsight/web/public/vue/{WikiPage-B2FYmeHL.js → WikiPage-Bfx92Iig.js} +1 -1
  43. data/lib/archsight/web/public/vue/index-B_YGrWD8.css +1 -0
  44. data/lib/archsight/web/public/vue/{index-CLuLTE5I.js → index-DMhBow5t.js} +2 -2
  45. data/lib/archsight/web/public/vue.html +2 -2
  46. metadata +18 -16
  47. data/lib/archsight/web/public/vue/InstanceRouter-DFW7tOrD.js +0 -1
  48. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0312d7e0c0790b8b2a117ad6277e49bfb18c61545134b27510fa3209e951697d
4
- data.tar.gz: 94915564bbbab8c59e139422c1a99317ec7fd5a87370e9905b16ed330cd56951
3
+ metadata.gz: 66e3a2aa9223a9583edf06749e12bafe346bfd562570d299795f2334510ed36a
4
+ data.tar.gz: 52a7997e394247a86da162fb4015d97b755b2ac2fe69cfadd758f6d3e140c33b
5
5
  SHA512:
6
- metadata.gz: 7b15fc5fb4130fe0783efe8e20cf7aa2ce3962e6566d54ae5c1c85834692e5363cc3a9667eb2f7cfbf17832badc0461cbf684c1d5a5c04711e5e529d1fb11bb2
7
- data.tar.gz: 36e154fb3fefd3dd0de59ced6d6f404e4238242ec182d78f5ced051fec01002a3af8dbe11c8eced3ff16fc978791301627fe3f06641e0ea859e9a529326ea814
6
+ metadata.gz: adc6fb4fad180ae14920ecfbccebfb242c9b3d48bc6bfb196b645de67f4dc490e4922274175fa9f942cfe4d80a327b8a4cf171ad9ccc1edd195c5d283c636bba
7
+ data.tar.gz: e82af637b926e46db4b61c23269d8f5b630222b78f6e169d413b21aa2f12858cbc80cacbc8325ab1d550174e11fedc8ca1780489d025c3dabe5c1943d1fd887b
data/docs/diagram.md CHANGED
@@ -43,6 +43,7 @@ archsight diagram three_tier.asd --relation=all # also show control/data-flow
43
43
  archsight diagram overview.asd --theme=compact # denser spacing + smaller fonts (or cozy, tight)
44
44
  archsight diagram overview.asd --legend=bottom # legend below (or right/left/top/none; default auto)
45
45
  archsight diagram *.asd # render every file to its own .svg
46
+ archsight diagram app.asd --resources=model/ # resolve include \"file://...\" paths from this directory
46
47
  archsight diagram --format *.asd # re-indent the sources in place
47
48
  archsight diagram --format --check *.asd # list unformatted sources, exit 1 if any (CI)
48
49
  ```
@@ -298,6 +299,69 @@ no broken styling. Library users pass a `resolver:` to `Diagram.render`
298
299
  (any `call(reference)` returning a URL, `nil` for a resource that exists but has nowhere to link to (the node stays as it is), `:missing` or `:ambiguous`, see
299
300
  `Archsight::Helpers::ResourceResolver`) and, to collect failures, `unresolved: []`.
300
301
 
302
+ ## Including diagrams
303
+
304
+ `include "<path>"` splices another diagram in at that position, so a part
305
+ that several diagrams share (a platform, a network, a team's services) is
306
+ written once:
307
+
308
+ ```
309
+ group "app" {
310
+ component "web" { label "Web server" }
311
+ stack {
312
+ include "archsight://ApplicationComponent/Platform:Database"
313
+ }
314
+ }
315
+ include "file://shared/network.asd"
316
+ web -> db
317
+ ```
318
+
319
+ The options of an `include` go in braces after the path (`namespace`, see below).
320
+
321
+ | Path | Included diagram |
322
+ |------|------------------|
323
+ | `archsight://<Kind>/<name>` | the `architecture/diagram` annotation of that resource |
324
+ | `file://<path>` | an `.asd` file, relative to the resources directory (also in nested includes) |
325
+
326
+ A path that leaves the resources directory (`..`, absolute paths, symlinks
327
+ pointing out) or is not an `.asd` file is refused. An `include` stands
328
+ wherever a `layer` or `stack` can: at the top level or inside a container
329
+ (not inside a leaf like `component`, and not together with `columns`). Included
330
+ diagrams may include others (up to 8 levels, 100 includes in all; a cycle is an
331
+ error). Nodes are one namespace across all of it, so an id defined twice is an
332
+ error, and an edge in either part may name a node of the other.
333
+
334
+ ### Including a diagram more than once
335
+
336
+ Ids are one namespace, so including the same diagram twice would define
337
+ every node twice. A `namespace` puts `<namespace>.` in front of every id the
338
+ included diagram defines (nodes and dataflows, and the references to them inside
339
+ it):
340
+
341
+ ```
342
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "a" }
343
+ include "archsight://ApplicationComponent/Platform:Server" { namespace "b" }
344
+ a.web -> b.db
345
+ ```
346
+
347
+ A node without a `label` keeps showing its own id (`web`, not `a.web`), and the same
348
+ id from different includes no longer clashes. A reference inside the included diagram to an id it
349
+ does not define names a node of the including diagram and stays as it is. Namespaces
350
+ nest: a diagram included with `namespace "o"` that itself includes one with `namespace "in"` has
351
+ `o.in.…` ids. A namespace is a name of letters, digits, `_` and `-`, joined by dots.
352
+
353
+ Dataflows and `theme`/`legend`/`ranks` apply to the whole diagram: an included
354
+ diagram holding them keeps them when it is included at the top level, and they are
355
+ ignored, with a warning, when it is included inside a container. The warning is
356
+ written to the server log and listed by `archsight lint`.
357
+
358
+ A missing resource or file, a bad path or a broken included diagram fails the
359
+ diagram with an error that names the include (`archsight lint` reports it). Standalone
360
+ `archsight diagram` takes `file://` paths relative to `--resources DIR` (default:
361
+ the directory of the input) and has no `archsight://`. Library users pass `includes:` to
362
+ `Diagram.render`, any `call(path)` returning the source or `:missing`, `:forbidden` or `:invalid`
363
+ (see `Archsight::Helpers::DiagramIncludes`), and `warnings: []` to collect the warnings.
364
+
301
365
  ## Diagrams in markdown
302
366
 
303
367
  A fenced code block tagged `asd` in any markdown the web UI renders (docs,
data/docs/modeling.md CHANGED
@@ -36,7 +36,7 @@ There are two families of verbs:
36
36
 
37
37
  | Family | Verbs | Written on | Points to |
38
38
  |--------|-------|------------|-----------|
39
- | Responsibility and provider (down) | `performedBy`, `ownedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
39
+ | Responsibility and provider (down) | `performedBy`, `ownedBy`, `operatedBy`, `executedBy`, `servedBy`, `guidedBy`, `maintainedBy`, `contributedBy`, `hasConcern`, `representedBy`, `realizedThrough`, `exposes`, `provisions`, `connects`, `mitigatedBy`, `influences`, `triggers`, `affects`, `assesses`, `contains`, `closedBy`, `compares` | the element that needs, uses or is concerned with something | what provides it or is responsible for it |
40
40
  | Realization (up) | `realizes`, `partiallyRealizes`, `plans`, `satisfies`, `evidencedBy` (on the evidenced element), `provides` | the concrete element | the abstract element it answers to (requirement, goal, capability, plateau) |
41
41
 
42
42
  Rules:
@@ -154,7 +154,7 @@ TechnologySystemSoftware, TechnologyService, TechnologyArtifact). The same princ
154
154
  | Location | In which region or country is it? | A place on a map, not a building; holds facilities | the place ("Location:Germany:Frankfurt") |
155
155
  | TechnologyFacility | In which building or site does hardware stand? | Has an address and can be visited; can be nested (campus, hall) | the site ("Facility:Frankfurt:HallA") |
156
156
  | TechnologyEquipment | Which physical thing can you touch? | Racks, fabrics, fibre routes, carrier circuits; no software of its own | the thing and its place ("Equipment:Link:Frankfurt-Madrid") |
157
- | TechnologyDevice | Which single machine stands in the room? | One server, switch, router, firewall or appliance with a serial number; it is cabled | the host ("Device:Frankfurt:Server01") |
157
+ | TechnologyDevice | Which single machine stands in the room? | One server, switch, router, firewall or appliance with a serial number (it is cabled), or one laptop, workstation or phone of a person or team | the host ("Device:Frankfurt:Server01") |
158
158
  | TechnologyCommunicationNetwork | What carries data between nodes? | Has a network role (`network/type`); exists beyond any single link | role and scope ("Network:Management:Frankfurt") |
159
159
  | TechnologyNode | On which machine does software run? | A VM, host role or logical group of machines with a lifecycle of its own | the host or host role ("Node:Frankfurt:Web01") |
160
160
  | TechnologySystemSoftware | What software hosts other software or data? | An operating system, database cluster, routing stack, platform | the product and role ("Tech:Routing:EVPN") |
@@ -166,8 +166,8 @@ TechnologySystemSoftware, TechnologyService, TechnologyArtifact). The same princ
166
166
  1. Is it a country, metro or region? Location.
167
167
  2. Is it a building, campus, hall or site that can be visited? TechnologyFacility.
168
168
  3. Is it a physical thing (rack, switch fabric, fibre, circuit) without software of its own? TechnologyEquipment.
169
- 4. Is it one physical machine (server, switch, router, firewall, appliance) whose room or cabling matters?
170
- TechnologyDevice.
169
+ 4. Is it one physical machine (server, switch, router, firewall, appliance, laptop, workstation, phone) whose room,
170
+ cabling or operating system matters? TechnologyDevice.
171
171
  5. Does it carry data between nodes, with a role such as underlay, overlay, management, public or VPN?
172
172
  TechnologyCommunicationNetwork.
173
173
  6. Is it a machine (virtual, or a host role) that software runs on? TechnologyNode.
@@ -201,7 +201,8 @@ TechnologySystemSoftware, TechnologyService, TechnologyArtifact). The same princ
201
201
  10. One `TechnologyDevice` per physical machine, `locatedAt` its room (a TechnologyFacility of `facility/type: room`
202
202
  the data center is `composedOf`). A group of machines with one role stays a logical `TechnologyNode` that is
203
203
  `realizedBy` its devices; a rack or hardware block is TechnologyEquipment `composedOf` them; a virtual chassis or
204
- stack is a device `composedOf` its members.
204
+ stack is a device `composedOf` its members. A client device (`device/type` laptop, desktop, workstation,
205
+ thin-client, tablet, mobile) moves and needs no room; it may be `locatedAt` an office.
205
206
  11. A network is `realizedBy` the devices that build it (switches, routers) and `connects` the devices attached to it.
206
207
  Both are written on the network, so a switch attached to the network it builds closes no cycle.
207
208
  12. Cables are `connectedTo` between devices, declared once on one of the two ends. Cabling runs both ways by nature
@@ -213,6 +214,17 @@ TechnologySystemSoftware, TechnologyService, TechnologyArtifact). The same princ
213
214
  `servedBy` its team and `composedOf` its devices, which then need no team of their own. Both relations point
214
215
  down to the team, which points to nothing further. On a TechnologyFacility, `servedBy` stays the operator of
215
216
  the site.
217
+ 14. A client device (developer workstation, laptop, phone) is a `TechnologyDevice` with a client `device/type` and
218
+ `device/os`, `device/osVersion`, `device/arch`, `device/ownership`, `device/managed` and `device/encryption`; the
219
+ vendor and product are in `device/model` (also for a hardened workstation that runs the user's operating system
220
+ virtualized beside other isolated sessions, which is a `workstation`). Model the individual machine when it is
221
+ inventoried, otherwise one `TechnologyNode` per client platform `realizedBy` the devices. The operating system is
222
+ `TechnologySystemSoftware` `realizedBy` the node, the image it is installed from an artifact the node is
223
+ `assignedTo`; each isolated session of a hardened workstation can be a node of its own. Network access is written
224
+ on the node (`servedBy` the networks it uses: the office, the VPN) or, for a single named machine, on the network
225
+ (`connects`), never on the device, because a network is `realizedBy` its switches and the reverse arrow would
226
+ close a cycle. A machine that reaches no network (air-gapped) has neither relation. Who uses it is the team or role
227
+ (`servedBy`), not a person; groups of machines are tags and the end of support is `lifecycle/endOfLifeAt`.
216
228
 
217
229
  **Checked by `archsight lint`**
218
230
 
@@ -224,7 +236,7 @@ As above, these are **warnings** in the "Modeling warnings" section and never fa
224
236
  | `TechnologyCommunicationNetwork 'Network:Overlay:EVPN' is an overlay network served by no network` | 3 |
225
237
  | `TechnologyFacility 'Facility:Madrid:DC' is located at no Location` | where |
226
238
  | `TechnologyEquipment 'Equipment:Rack01' is located at no TechnologyFacility` | where |
227
- | `TechnologyDevice 'Device:Server01' is located at no TechnologyFacility` | 10 |
239
+ | `TechnologyDevice 'Device:Server01' is located at no TechnologyFacility` (not for client device types) | 10 |
228
240
  | `TechnologyDevice 'Device:Switch01' is connectedTo itself` | 12 |
229
241
  | `TechnologyDevice 'Device:Router01' is a router that realizes no TechnologyCommunicationNetwork` | 11 |
230
242
  | `TechnologyDevice 'Device:a:server-3' is servedBy 'Kernel:Team', not by the team of its equipment ('Compute:Team')` | 13 |
@@ -547,7 +559,7 @@ Which kind to pick is in [Choosing the Kind: Infrastructure](#choosing-the-kind-
547
559
  | `TechnologyCommunicationNetwork` | `realizedBy` | `TechnologyEquipment` (the fabric), `TechnologySystemSoftware` (routing stack, VPN software) |
548
560
  | `TechnologyCommunicationNetwork` | `realizedBy` | `ApplicationComponent`, `ApplicationService` (software that builds the network: an SDN agent, a network operator) |
549
561
  | `TechnologyCommunicationNetwork` | `realizedBy` | `TechnologyDevice` (the switches and routers that build it) |
550
- | `TechnologyCommunicationNetwork` | `connects` | `TechnologyDevice` (the devices attached to it) |
562
+ | `TechnologyCommunicationNetwork` | `connects` | `TechnologyDevice` (the devices attached to it, a named workstation behind a VPN) |
551
563
  | `TechnologyNode` | `servedBy` | `TechnologyCommunicationNetwork` |
552
564
  | `ApplicationInterface` | `servedBy` | `TechnologyCommunicationNetwork` (the networks on which it can be reached) |
553
565
 
@@ -593,7 +605,7 @@ center is not a computational resource.
593
605
  | Location | For a country, metro or region |
594
606
  | TechnologyFacility | For a data center, colocation, office or edge site (`facility/type`), also a campus or hall inside one |
595
607
  | TechnologyEquipment | Optional: racks, network fabrics, physical clusters inside a facility (`equipment/type`) |
596
- | TechnologyDevice | Optional: one physical machine, `locatedAt` its room (`facility/type: room`) |
608
+ | TechnologyDevice | Optional: one physical machine, `locatedAt` its room (`facility/type: room`); a client device (laptop, workstation, phone) needs no room |
597
609
 
598
610
  Relations are written on the element that is placed (see [Direction of Relations](#direction-of-relations)):
599
611
 
@@ -604,6 +616,8 @@ Relations are written on the element that is placed (see [Direction of Relations
604
616
  | `TechnologyFacility` | `composedOf` | `TechnologyFacility` (nested sites, the rooms of a data center) |
605
617
  | `TechnologyFacility` | `servedBy` | `BusinessActor` (the operator) |
606
618
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode`, `TechnologyCommunicationNetwork` | `ownedBy` | `BusinessActor`, `BusinessRole` (the team that operates it) |
619
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `operatedBy` | `BusinessActor`, `BusinessRole` (the team that runs it in production) |
620
+ | `ApplicationComponent`, `ApplicationService`, `TechnologyService` | `maintainedBy` | `BusinessActor` (the team that builds and changes it) |
607
621
  | `TechnologyDevice`, `TechnologyEquipment`, `TechnologyNode` | `servedBy` | `BusinessActor` (the team it is dedicated to, e.g. a test partition) |
608
622
 
609
623
  `locatedAt` with many targets is the point: a service that runs in three data centers is **one** service with three
@@ -688,13 +702,19 @@ depend on each other, so `dependsOn` is exempt from the cycle check. In `example
688
702
 
689
703
  ### Where a Component Runs
690
704
 
691
- Three relations say what something runs on; each answers a different question:
705
+ These relations say what something runs on or builds; each answers a different question:
692
706
 
693
707
  | Relation | Means | Example |
694
708
  |----------|-------|---------|
695
709
  | ApplicationComponent `servedBy technologyServices` | the platform service the component is deployed on | `Payments:API` runs in `Payments:Cluster` |
696
710
  | ApplicationComponent `servedBy technologySystemSoftware` | the runtime it runs on | a Kubernetes distribution, a JVM |
697
711
  | ApplicationService `servedBy technologyServices` | a platform service the service as a whole uses | a managed database, the clusters of its own components |
712
+ | ApplicationComponent `provisions technologyServices` | the platform service the component creates and keeps in its desired state | an operator that builds the clusters of a product |
713
+
714
+ `provisions` is written on the component, like every relation, so it points down to what the component creates: the
715
+ service page lists its provisioner as incoming, and the impact of a provisioner is its outgoing relations (the
716
+ services it builds). It is not a runtime dependency: a provisioned service keeps running while its provisioner is
717
+ down, only changes stop. `suppliedBy` on the service stays for what it is made of (the system software).
698
718
 
699
719
  Deployment importers (Argo CD, Helm, Cluster API) know every cluster a deployable runs in and write the relation per
700
720
  component. Keep the service-level relation to the clusters of the service's own components: a sidecar or agent that
@@ -997,7 +1017,7 @@ is a sidebar filter and query term (`lifecycle/status == "deprecated"`).
997
1017
  |------------|---------|
998
1018
  | `lifecycle/status` | The state above |
999
1019
  | `lifecycle/activeAt`, `lifecycle/deprecatedAt`, `lifecycle/sunsetAt`, `lifecycle/endOfLifeAt` | When it happened or will happen (ISO 8601 date or time, checked by `archsight lint`) |
1000
- | `lifecycle/replacedBy` | The resource that replaces it: a name for a resource of the same kind, `Kind/Name` for another kind (a component replaced by an `ApplicationService/...`). Shown as a link, `archsight lint` checks that it exists |
1020
+ | `lifecycle/replacedBy` | The resource that replaces it: a name for a resource of the same kind, `Kind/Name` for another kind (a component replaced by an `ApplicationService/...`). Shown as a link behind the status, `archsight lint` checks that it exists |
1001
1021
  | `lifecycle/reason` | Why, in markdown |
1002
1022
 
1003
1023
  ```yaml
@@ -1010,6 +1030,9 @@ metadata:
1010
1030
  lifecycle/replacedBy: ApplicationService/ExportV2
1011
1031
  ```
1012
1032
 
1033
+ The detail page shows the status, the replacement as a link and, for a `deprecated`, `sunset` or `end-of-life`
1034
+ resource, the number of resources that still refer to it (a link to its Incoming relations) on one line.
1035
+
1013
1036
  ## Common Patterns
1014
1037
 
1015
1038
  ### Microservice
data/lib/archsight/cli.rb CHANGED
@@ -279,6 +279,7 @@ module Archsight
279
279
  option :theme, type: :string, desc: "Spacing/font-size theme (default: the file's own `theme` statement)"
280
280
  option :legend, type: :string, desc: "Legend placement (default: the file's own `legend` statement, else auto)"
281
281
  option :profile, aliases: "-p", type: :boolean, desc: "Print stage timing stats after each render"
282
+ option :resources, aliases: "-r", type: :string, desc: "Directory that `include \"file://...\"` paths are relative to (default: the input's directory)"
282
283
  option :format, type: :boolean, desc: "Re-indent the inputs in place instead of rendering them (-o: write a single input there)"
283
284
  option :check, type: :boolean, desc: "With --format: only list the inputs that are not formatted, exit 1 if any"
284
285
  def diagram(*inputs)
@@ -295,7 +296,7 @@ module Archsight
295
296
 
296
297
  status = diagram::CLI.new.run_with(
297
298
  inputs: inputs, output: options[:output], watch: options[:watch], profile: options[:profile],
298
- format: options[:format], check: options[:check],
299
+ format: options[:format], check: options[:check], resources: options[:resources],
299
300
  style: options[:style], theme: options[:theme], legend: options[:legend],
300
301
  relation_filter: if relation.nil?
301
302
  diagram::Relations::DEFAULT_FILTER
@@ -109,11 +109,12 @@ module Archsight
109
109
  matches.first if matches.one?
110
110
  end
111
111
 
112
- # Collect unique annotation values across all instances of a kind
112
+ # Annotation values across all instances of a kind with the number of instances using each,
113
+ # most used first (ties by value): [[value, count], ...]
113
114
  def annotation_values(kind, annotation)
114
115
  instances = instances_by_kind(kind).values
115
- values = instances.flat_map { |inst| Array(annotation.value_for(inst)) }
116
- values.compact.uniq.sort
116
+ counts = instances.flat_map { |inst| Array(annotation.value_for(inst)).compact.uniq }.tally
117
+ counts.sort_by { |value, count| [-count, value.to_s] }
117
118
  end
118
119
 
119
120
  # Get filterable annotations with their values for a kind (excludes empty)
@@ -3,6 +3,7 @@
3
3
  require "optparse"
4
4
  require_relative "."
5
5
  require_relative "formatter"
6
+ require_relative "../helpers/diagram_includes"
6
7
 
7
8
  module Archsight
8
9
  module Diagram
@@ -79,6 +80,8 @@ module Archsight
79
80
  opts.on("-p", "--profile", "Print stage timing stats after each render") do
80
81
  options[:profile] = true
81
82
  end
83
+ opts.on("--resources=DIR", "Directory that include \"file://...\" paths are relative to " \
84
+ "(default: the input file's directory)") { |v| options[:resources] = v }
82
85
  opts.on("--format", "Re-indent the inputs in place instead of rendering them (-o: write a single input there)") do
83
86
  options[:format] = true
84
87
  end
@@ -155,7 +158,9 @@ module Archsight
155
158
  total_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
156
159
 
157
160
  svg = Archsight::Diagram.render(File.read(input), relation_filter: options[:relation_filter], profile: stats,
158
- style: options[:style], theme: options[:theme], legend: options[:legend])
161
+ style: options[:style], theme: options[:theme], legend: options[:legend],
162
+ includes: includes_for(input, options), warnings: warnings = [])
163
+ warnings.each { |message| warn "archsight-diagram: #{input}: #{message}" }
159
164
 
160
165
  write_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) if profile
161
166
  File.write(output, svg)
@@ -172,6 +177,11 @@ module Archsight
172
177
  1
173
178
  end
174
179
 
180
+ # Standalone, `include` reads `file://` paths only (there is no model for `archsight://`)
181
+ def includes_for(input, options)
182
+ Helpers::DiagramIncludes.new(nil, resources_dir: options[:resources] || File.dirname(File.expand_path(input)))
183
+ end
184
+
175
185
  def print_stats(stats, total_start)
176
186
  total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_start
177
187
  parts = stats.map { |key, seconds| "#{key}=#{format("%.1fms", seconds * 1000)}" }
@@ -5,5 +5,6 @@ module Archsight
5
5
  class Error < StandardError; end
6
6
  class ParseError < Error; end
7
7
  class GraphError < Error; end
8
+ class IncludeError < Error; end
8
9
  end
9
10
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Diagram
5
+ # Expands `include "<path>"` statements: each one is replaced, at its position, by the statements of the
6
+ # diagram it names. The renderer knows nothing about where diagrams live; the caller supplies a loader
7
+ # (`archsight://` and `file://` paths, see `Helpers::DiagramIncludes`).
8
+ #
9
+ # `loader.call(path)` returns the source of the included diagram, or `:missing` / `:forbidden` / `:invalid`.
10
+ # Included sources may include others. An include inside a container takes blocks and edges only: a
11
+ # `dataflow` or a `theme`/`legend`/`ranks` setting belongs to the whole diagram, so it is left out there
12
+ # and reported in `Result#warnings`.
13
+ # `include "x" { namespace "cs" }` puts `cs.` in front of every id the included diagram defines (nodes and
14
+ # dataflows, and the references to them inside it), so the same diagram can be included more than once;
15
+ # a node without a `label` keeps showing its unprefixed id. References to ids the diagram does not define
16
+ # are left alone: they name nodes of the including diagram.
17
+ # Edges need no such care, they are resolved by id once the whole graph is built, so a fragment may link to
18
+ # nodes of the diagram that includes it and the other way round.
19
+ class Includes
20
+ MAX_DEPTH = 8
21
+ MAX_INCLUDES = 100
22
+ REASONS = { missing: "not found", forbidden: "is not allowed", invalid: "is not a valid include path" }.freeze
23
+
24
+ # The expanded statements, the sources that were loaded (`{ path => source }`, in order) and what was
25
+ # left out of them (warning messages).
26
+ Result = Struct.new(:statements, :sources, :warnings, keyword_init: true)
27
+
28
+ # @param statements [Array] parsed statements of the main source
29
+ # @param loader [#call, nil]
30
+ # @raise [IncludeError]
31
+ def self.expand(statements, loader)
32
+ new(loader).expand(statements)
33
+ end
34
+
35
+ def self.includes?(statements)
36
+ statements.any? { |s| s.is_a?(AST::Include) || (s.is_a?(AST::Block) && includes?(s.children)) }
37
+ end
38
+
39
+ def initialize(loader)
40
+ @loader = loader
41
+ @sources = {}
42
+ @warnings = []
43
+ @count = 0
44
+ end
45
+
46
+ def expand(statements)
47
+ Result.new(statements: splice(statements, [], top: true), sources: @sources, warnings: @warnings)
48
+ end
49
+
50
+ private
51
+
52
+ def splice(statements, stack, top:)
53
+ statements.flat_map do |stmt|
54
+ case stmt
55
+ when AST::Include then included(stmt, stack, top: top)
56
+ when AST::Block
57
+ stmt.children = splice(stmt.children, stack, top: false)
58
+ [stmt]
59
+ else [stmt]
60
+ end
61
+ end
62
+ end
63
+
64
+ def included(stmt, stack, top:)
65
+ path = stmt.path
66
+ raise IncludeError, "include cycle: #{(stack + [path]).join(" -> ")} (line #{stmt.line})" if stack.include?(path)
67
+ raise IncludeError, "include #{path.inspect} (line #{stmt.line}) is nested deeper than #{MAX_DEPTH} levels" if stack.length >= MAX_DEPTH
68
+
69
+ @count += 1
70
+ raise IncludeError, "too many includes (more than #{MAX_INCLUDES})" if @count > MAX_INCLUDES
71
+
72
+ source = load(path, stmt.line)
73
+ @sources[path] = source
74
+ parsed = parse(path, source)
75
+ parsed = drop_whole_diagram_statements(path, parsed, stmt.line) unless top
76
+ statements = splice(parsed, stack + [path], top: top)
77
+ stmt.namespace ? Namespacer.new(stmt.namespace).apply(statements) : statements
78
+ end
79
+
80
+ def load(path, line)
81
+ raise IncludeError, "include #{path.inspect} (line #{line}) is not available here" unless @loader
82
+
83
+ result = @loader.call(path)
84
+ return result if result.is_a?(String)
85
+
86
+ reason = REASONS.fetch(result) { raise ArgumentError, "include loader returned #{result.inspect} for #{path.inspect}" }
87
+ raise IncludeError, "include #{path.inspect} #{reason} (line #{line})"
88
+ end
89
+
90
+ def parse(path, source)
91
+ Parser.parse(source, anon_scope: "i#{@count}_")
92
+ rescue ParseError, GraphError => e
93
+ raise IncludeError, "in include #{path.inspect}: #{e.message}"
94
+ end
95
+
96
+ # Dataflows and settings apply to the whole diagram, so inside a container they are ignored (with a warning)
97
+ def drop_whole_diagram_statements(path, parsed, line)
98
+ dropped, kept = parsed.partition { |s| s.is_a?(AST::DataFlow) || s.is_a?(AST::Setting) }
99
+ dropped.each do |s|
100
+ what = s.is_a?(AST::DataFlow) ? "dataflow #{s.id.inspect}" : "#{s.key} setting"
101
+ @warnings << "include #{path.inspect} (line #{line}): the #{what} (line #{s.line}) is ignored, it only applies when included at the top level"
102
+ end
103
+ kept
104
+ end
105
+
106
+ # Puts `<namespace>.` in front of the ids a list of statements defines, and of the references to them
107
+ class Namespacer
108
+ def initialize(namespace)
109
+ @prefix = "#{namespace}."
110
+ @defined = {}
111
+ end
112
+
113
+ def apply(statements)
114
+ collect(statements)
115
+ statements.each { |stmt| rename(stmt) }
116
+ statements
117
+ end
118
+
119
+ private
120
+
121
+ # Anonymous layers/stacks have ids no one can reference and that are unique already
122
+ def collect(statements)
123
+ statements.each do |stmt|
124
+ case stmt
125
+ when AST::Block
126
+ @defined[stmt.id] = true unless stmt.anonymous?
127
+ collect(stmt.children)
128
+ when AST::DataFlow then @defined[stmt.id] = true
129
+ end
130
+ end
131
+ end
132
+
133
+ def rename(stmt)
134
+ case stmt
135
+ when AST::Block then rename_block(stmt)
136
+ when AST::Edge
137
+ stmt.from = ref(stmt.from)
138
+ stmt.to = ref(stmt.to)
139
+ when AST::DataFlow then rename_dataflow(stmt)
140
+ end
141
+ end
142
+
143
+ def rename_block(block)
144
+ unless block.anonymous?
145
+ block.attrs["label"] ||= block.id # the label defaults to the id
146
+ block.id = ref(block.id)
147
+ end
148
+ block.children.each { |child| rename(child) }
149
+ end
150
+
151
+ def rename_dataflow(flow)
152
+ flow.attrs["label"] ||= flow.id
153
+ flow.id = ref(flow.id)
154
+ flow.hops.map! { |hop| hop.is_a?(AST::DataFlowGroup) ? rename_group(hop) : ref(hop) }
155
+ end
156
+
157
+ def rename_group(group)
158
+ group.branches.each { |branch| branch.hops.map! { |hop| ref(hop) } }
159
+ group
160
+ end
161
+
162
+ def ref(id)
163
+ @defined[id] ? "#{@prefix}#{id}" : id
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -43,6 +43,11 @@ module Archsight
43
43
  # own attrs at graph-resolution time).
44
44
  DataFlowBranch = Struct.new(:hops, :attrs, :line, keyword_init: true)
45
45
 
46
+ # `include "<path>" [{ namespace "cs" }]` -- another diagram spliced in at this position (see `Includes`).
47
+ # Parsed like any statement, replaced by the included statements before the graph is built, so nothing
48
+ # downstream sees it. `namespace`, if any, puts `<namespace>.` in front of every id the included diagram defines.
49
+ Include = Struct.new(:path, :namespace, :line, keyword_init: true)
50
+
46
51
  # A top-level diagram-wide `key "value"` setting -- currently only
47
52
  # `theme "compact"` (see `Theme`).
48
53
  Setting = Struct.new(:key, :value, :line, keyword_init: true)
@@ -20,6 +20,8 @@ module Archsight
20
20
 
21
21
  raise ParseError, "#{label} at line #{line}: columns must be a positive integer, got #{raw.inspect}" unless raw.match?(/\A[1-9]\d*\z/)
22
22
 
23
+ raise ParseError, "#{label} at line #{line}: columns cannot be combined with include (the included blocks are not known yet)" if children.any?(AST::Include)
24
+
23
25
  blocks, edges = children.partition { |c| c.is_a?(AST::Block) }
24
26
  count = [raw.to_i, blocks.length].min
25
27
  return children if blocks.empty?
@@ -14,9 +14,11 @@ module Archsight
14
14
  #
15
15
  # Grammar (informal):
16
16
  # program := statement*
17
- # statement := block | dataflow | edge | setting
17
+ # statement := block | dataflow | edge | setting | include
18
+ # include := "include" STRING ["{" "namespace" STRING "}"] [";"]
19
+ # -- top level, or where a container may hold blocks and edges
18
20
  # setting := ("theme" | "legend" | "ranks") STRING [";"] -- top level only
19
- # block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge)* "}"
21
+ # block := (CONTAINER_KEYWORD | LEAF_KEYWORD) [STRING] "{" (attr | block | edge | include)* "}"
20
22
  # -- the id is required except for ANONYMOUS_KEYWORDS
21
23
  # dataflow := "dataflow" STRING "{" (hop | hop_group | attr)* "}"
22
24
  # -- needs >= 2 hops (per branch, if a hop_group is present);
@@ -45,6 +47,7 @@ module Archsight
45
47
  # `layer`/`stack` may be used without an id, purely as a layout hint
46
48
  # (see AST::Block#anonymous) — a `group`/`boundary` always renders a
47
49
  # visible box, so an unnamed one wouldn't make sense the same way.
50
+ NAMESPACE = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*\z/
48
51
  ANONYMOUS_KEYWORDS = %w[layer stack].freeze
49
52
  SETTING_KEYWORDS = %w[theme legend ranks].freeze
50
53
  EDGE_ARROWS = {
@@ -55,13 +58,16 @@ module Archsight
55
58
 
56
59
  attr_reader :cursor
57
60
 
58
- def self.parse(source)
59
- new(source).parse
61
+ # `anon_scope` namespaces the ids of anonymous layers/stacks, so the statements of an included
62
+ # source (parsed on its own) never collide with those of the including one.
63
+ def self.parse(source, anon_scope: nil)
64
+ new(source, anon_scope: anon_scope).parse
60
65
  end
61
66
 
62
- def initialize(source)
67
+ def initialize(source, anon_scope: nil)
63
68
  @cursor = TokenCursor.new(Lexer.new(source).tokenize)
64
69
  @anon_counter = 0
70
+ @anon_scope = anon_scope
65
71
  end
66
72
 
67
73
  def parse
@@ -75,6 +81,8 @@ module Archsight
75
81
  def statement
76
82
  if check_block_keyword?
77
83
  block
84
+ elsif include_directive?
85
+ include_statement
78
86
  elsif cursor.check(:ident, "dataflow") && cursor.peek_type(1) == :string
79
87
  DataflowParser.new(cursor).parse
80
88
  elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
@@ -108,22 +116,16 @@ module Archsight
108
116
  children = []
109
117
 
110
118
  parse_attrs_until_rbrace(attrs, label, expected: "an attribute, a nested block, an edge") do
111
- if check_block_keyword?
112
- children << block
113
- true
114
- elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
115
- children << edge
116
- true
117
- else
118
- false
119
- end
119
+ child = container_child
120
+ children << child if child
121
+ child
120
122
  end
121
123
 
122
124
  cursor.expect(:rbrace, "expected '}' to close #{label}")
123
125
 
124
126
  if LEAF_KEYWORDS.include?(kind_token.value) && !children.empty?
125
127
  raise ParseError, "#{label} at line #{kind_token.line} cannot " \
126
- "contain nested blocks or edges (did you mean 'group'?)"
128
+ "contain nested blocks, edges or includes (did you mean 'group'?)"
127
129
  end
128
130
 
129
131
  raise ParseError, "#{label} at line #{kind_token.line}: columns only applies to containers" if LEAF_KEYWORDS.include?(kind_token.value) && attrs.key?("columns")
@@ -133,6 +135,46 @@ module Archsight
133
135
  AST::Block.new(kind: kind, id: id, anonymous: anonymous, attrs: attrs, children: children, line: kind_token.line)
134
136
  end
135
137
 
138
+ # The nested block, edge or include at the cursor, nil if it is something else (an attribute)
139
+ def container_child
140
+ if check_block_keyword?
141
+ block
142
+ elsif include_directive?
143
+ include_statement
144
+ elsif cursor.check(:ident) && EDGE_ARROWS.key?(cursor.peek_type(1))
145
+ edge
146
+ end
147
+ end
148
+
149
+ # `include "<path>"` -- `include -> x` is still an edge, so a node may be named "include"
150
+ def include_directive?
151
+ cursor.check(:ident, "include") && cursor.peek_type(1) == :string
152
+ end
153
+
154
+ def include_statement
155
+ keyword = cursor.advance
156
+ path = cursor.advance
157
+ namespace = include_options(path.value) if cursor.check(:lbrace)
158
+ cursor.consume(:semi)
159
+ AST::Include.new(path: path.value, namespace: namespace, line: keyword.line)
160
+ end
161
+
162
+ # `{ namespace "cs" }` after an include: the only option
163
+ def include_options(path)
164
+ cursor.advance
165
+ options = {}
166
+ until cursor.check(:rbrace)
167
+ line = cursor.current.line
168
+ key, value = cursor.check(:ident) ? attr : cursor.expect(:ident, "expected 'namespace' in the options of include #{path.inspect}")
169
+ raise ParseError, "unknown option '#{key}' of include #{path.inspect} at line #{line} (only 'namespace')" unless key == "namespace"
170
+ raise ParseError, "the namespace #{value.inspect} of include #{path.inspect} at line #{line} must be a name like \"cs\" or \"a.b\"" unless value.match?(NAMESPACE)
171
+
172
+ store_attr(options, key, value, line, "include #{path.inspect}")
173
+ end
174
+ cursor.expect(:rbrace, "expected '}' to close the options of include #{path.inspect}")
175
+ options["namespace"]
176
+ end
177
+
136
178
  # `theme "compact"`, `legend "right"` -- `theme -> x` is still an edge
137
179
  # (an arrow, not a string, follows the id), so a node may still be
138
180
  # named "theme" or "legend".
@@ -145,7 +187,7 @@ module Archsight
145
187
 
146
188
  def next_anonymous_id
147
189
  @anon_counter += 1
148
- "__asd_anon_#{@anon_counter}"
190
+ "__asd_anon_#{@anon_scope}#{@anon_counter}"
149
191
  end
150
192
 
151
193
  # What a human should see identifying this block in an error message