archsight 0.3.3 → 0.4.1

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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/docs/archimate.md +21 -1
  4. data/docs/configuration.md +1 -0
  5. data/docs/diagram.md +15 -3
  6. data/docs/docker.md +4 -1
  7. data/docs/icons.md +8 -2
  8. data/docs/import.md +178 -55
  9. data/docs/index.md.erb +24 -18
  10. data/docs/modeling.md +645 -34
  11. data/docs/pages.md +46 -15
  12. data/docs/search.md +21 -1
  13. data/docs/togaf.md +2 -1
  14. data/lib/archsight/analysis/result.rb +8 -2
  15. data/lib/archsight/analysis/sandbox.rb +8 -0
  16. data/lib/archsight/analysis/typst_renderer.rb +245 -0
  17. data/lib/archsight/analysis.rb +1 -0
  18. data/lib/archsight/annotations/annotation.rb +30 -3
  19. data/lib/archsight/annotations/architecture_annotations.rb +47 -0
  20. data/lib/archsight/annotations/link_annotations.rb +15 -0
  21. data/lib/archsight/annotations/resource_reference.rb +12 -0
  22. data/lib/archsight/cli.rb +124 -23
  23. data/lib/archsight/database.rb +40 -0
  24. data/lib/archsight/diagram/cli.rb +50 -2
  25. data/lib/archsight/diagram/formatter.rb +112 -0
  26. data/lib/archsight/diagram/routing/edge_router/orthogonal_path.rb +38 -10
  27. data/lib/archsight/diagram/routing/edge_router.rb +29 -4
  28. data/lib/archsight/diagram/routing/edge_routing.rb +61 -5
  29. data/lib/archsight/diagram/style/theme.rb +7 -2
  30. data/lib/archsight/documentation.rb +4 -4
  31. data/lib/archsight/editor/page_source.rb +13 -1
  32. data/lib/archsight/export/confluence/analysis_output.rb +100 -0
  33. data/lib/archsight/export/confluence/client.rb +13 -0
  34. data/lib/archsight/export/confluence/diagram_links.rb +1 -1
  35. data/lib/archsight/export/confluence/exporter.rb +43 -14
  36. data/lib/archsight/export/confluence/labels.rb +25 -0
  37. data/lib/archsight/export/confluence/page_properties.rb +6 -9
  38. data/lib/archsight/export/confluence/storage.rb +63 -14
  39. data/lib/archsight/export/confluence/tables.rb +2 -3
  40. data/lib/archsight/git_source.rb +81 -0
  41. data/lib/archsight/helpers/asset_images.rb +3 -4
  42. data/lib/archsight/helpers/embeds.rb +18 -7
  43. data/lib/archsight/helpers/resource_resolver.rb +25 -0
  44. data/lib/archsight/import/executor.rb +140 -16
  45. data/lib/archsight/import/handler.rb +55 -6
  46. data/lib/archsight/import/handler_loader.rb +64 -0
  47. data/lib/archsight/import/handlers/cache_pruner.rb +30 -7
  48. data/lib/archsight/import/handlers/command.rb +120 -0
  49. data/lib/archsight/import/handlers/github.rb +4 -1
  50. data/lib/archsight/import/handlers/gitlab.rb +4 -1
  51. data/lib/archsight/import/handlers/go_dep_resolver.rb +10 -2
  52. data/lib/archsight/import/handlers/go_grapher.rb +102 -5
  53. data/lib/archsight/import/handlers/go_module_parser.rb +88 -0
  54. data/lib/archsight/import/handlers/grapher.rb +40 -1
  55. data/lib/archsight/import/handlers/repository.rb +3 -0
  56. data/lib/archsight/import/handlers/rest_api_index.rb +3 -0
  57. data/lib/archsight/import/registry.rb +22 -1
  58. data/lib/archsight/import.rb +89 -1
  59. data/lib/archsight/linter/structure.rb +239 -0
  60. data/lib/archsight/linter.rb +123 -16
  61. data/lib/archsight/markdown.rb +48 -0
  62. data/lib/archsight/openapi.rb +169 -0
  63. data/lib/archsight/page_loader.rb +12 -1
  64. data/lib/archsight/page_tree.rb +2 -3
  65. data/lib/archsight/query/evaluator.rb +13 -6
  66. data/lib/archsight/query/parser.rb +73 -6
  67. data/lib/archsight/query.rb +1 -1
  68. data/lib/archsight/references.rb +3 -3
  69. data/lib/archsight/renderer.rb +80 -23
  70. data/lib/archsight/resources/analysis.rb +3 -2
  71. data/lib/archsight/resources/application_component.rb +75 -7
  72. data/lib/archsight/resources/application_interface.rb +12 -0
  73. data/lib/archsight/resources/application_service.rb +12 -1
  74. data/lib/archsight/resources/base.rb +58 -5
  75. data/lib/archsight/resources/business_actor.rb +5 -0
  76. data/lib/archsight/resources/business_contract.rb +72 -0
  77. data/lib/archsight/resources/business_product.rb +12 -0
  78. data/lib/archsight/resources/business_role.rb +1 -0
  79. data/lib/archsight/resources/implementation_deliverable.rb +1 -0
  80. data/lib/archsight/resources/implementation_plateau.rb +8 -2
  81. data/lib/archsight/resources/implementation_work_package.rb +2 -1
  82. data/lib/archsight/resources/import.rb +37 -11
  83. data/lib/archsight/resources/location.rb +56 -0
  84. data/lib/archsight/resources/motivation_requirement.rb +6 -0
  85. data/lib/archsight/resources/motivation_stakeholder.rb +14 -0
  86. data/lib/archsight/resources/page.rb +4 -7
  87. data/lib/archsight/resources/strategy_capability.rb +9 -0
  88. data/lib/archsight/resources/technology_artifact.rb +11 -4
  89. data/lib/archsight/resources/technology_communication_network.rb +91 -0
  90. data/lib/archsight/resources/technology_device.rb +88 -0
  91. data/lib/archsight/resources/technology_equipment.rb +75 -0
  92. data/lib/archsight/resources/technology_facility.rb +70 -0
  93. data/lib/archsight/resources/technology_node.rb +50 -0
  94. data/lib/archsight/resources/technology_service.rb +33 -0
  95. data/lib/archsight/resources/technology_system_software.rb +28 -0
  96. data/lib/archsight/resources.rb +12 -0
  97. data/lib/archsight/version.rb +1 -1
  98. data/lib/archsight/web/api/analysis_helpers.rb +55 -0
  99. data/lib/archsight/web/api/docs.rb +2 -1
  100. data/lib/archsight/web/api/json_helpers.rb +3 -37
  101. data/lib/archsight/web/api/openapi/spec.yaml +146 -35
  102. data/lib/archsight/web/api/page_helpers.rb +1 -8
  103. data/lib/archsight/web/api/routes.rb +9 -4
  104. data/lib/archsight/web/application.rb +40 -4
  105. data/lib/archsight/web/editor/routes.rb +2 -1
  106. data/lib/archsight/web/public/vendor/redoc/LICENSE +22 -0
  107. data/lib/archsight/web/public/vendor/redoc/VERSION +1 -0
  108. data/lib/archsight/web/public/vendor/redoc/api.html +27 -0
  109. data/lib/archsight/web/public/vendor/redoc/api.js +19 -0
  110. data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js +1851 -0
  111. data/lib/archsight/web/public/vendor/redoc/redoc.standalone.js.LICENSE.txt +102 -0
  112. data/lib/archsight/web/public/vue/DocPage-C2ubTXH7.js +1 -0
  113. data/lib/archsight/web/public/vue/EditorPage-Dso6PLMn.js +35 -0
  114. data/lib/archsight/web/public/vue/ErrorPage-DnZ6YJLi.js +2 -0
  115. data/lib/archsight/web/public/vue/GraphView-CTl03Yre.js +1 -0
  116. data/lib/archsight/web/public/vue/HomePage-C339nihQ.js +2 -0
  117. data/lib/archsight/web/public/vue/InstanceRouter-DFW7tOrD.js +1 -0
  118. data/lib/archsight/web/public/vue/InstanceRouter-ZaCcjlIX.css +1 -0
  119. data/lib/archsight/web/public/vue/KindList-C0oaM3CF.js +1 -0
  120. data/lib/archsight/web/public/vue/KindList-yYcRT2BV.css +1 -0
  121. data/lib/archsight/web/public/vue/PageView-B93woeN3.css +1 -0
  122. data/lib/archsight/web/public/vue/PageView-OZB6G9Wu.js +1 -0
  123. data/lib/archsight/web/public/vue/QueryError-C2xLkCEm.js +1 -0
  124. data/lib/archsight/web/public/vue/ResourceList-D75O87zG.css +1 -0
  125. data/lib/archsight/web/public/vue/ResourceList-DooWFwQP.js +2 -0
  126. data/lib/archsight/web/public/vue/SearchResults-BK-HjFnv.js +1 -0
  127. data/lib/archsight/web/public/vue/{SearchResults-BewvsfOc.css → SearchResults-BV6UHriJ.css} +1 -1
  128. data/lib/archsight/web/public/vue/WikiPage-B2FYmeHL.js +3 -0
  129. data/lib/archsight/web/public/vue/WikiPage-TpmM1VAF.css +1 -0
  130. data/lib/archsight/web/public/vue/architecture-WOLXFQ4H-BGN-TV5r.js +1 -0
  131. data/lib/archsight/web/public/vue/cynefin-EF2NZ3EQ-CZd6g_tq.js +1 -0
  132. data/lib/archsight/web/public/vue/eventmodeling-K75KTNOO-CYnbEx8O.js +1 -0
  133. data/lib/archsight/web/public/vue/gitGraph-VSP46ZUC-DialfQSk.js +1 -0
  134. data/lib/archsight/web/public/vue/graphviz-1lWBhpEX.js +3 -0
  135. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +1 -0
  136. data/lib/archsight/web/public/vue/index-CLuLTE5I.js +3 -0
  137. data/lib/archsight/web/public/vue/info-OHQRW6UA-BuhOW7ss.js +1 -0
  138. data/lib/archsight/web/public/vue/katex-B_JcIwqj.js +257 -0
  139. data/lib/archsight/web/public/vue/mermaid-C3bOamfn.js +3999 -0
  140. data/lib/archsight/web/public/vue/packet-JDAUHWVQ-CUeRz7w4.js +1 -0
  141. data/lib/archsight/web/public/vue/pie-XZMESJXO-DnuewJI6.js +1 -0
  142. data/lib/archsight/web/public/vue/radar-ABXABTNO-Bi9l8bcv.js +1 -0
  143. data/lib/archsight/web/public/vue/railroad-I3PHUGI6-DSlxFbxk.js +1 -0
  144. data/lib/archsight/web/public/vue/railroad-abnf-LNEFI6M7-D6Kqu8wu.js +1 -0
  145. data/lib/archsight/web/public/vue/railroad-ebnf-DWYD2UWJ-MR7uCYT0.js +1 -0
  146. data/lib/archsight/web/public/vue/railroad-peg-JR7BVNR7-BL7aKIc-.js +1 -0
  147. data/lib/archsight/web/public/vue/rolldown-runtime-COiEfhsA.js +1 -0
  148. data/lib/archsight/web/public/vue/treeView-D4JQ5SDB-CamtB04D.js +1 -0
  149. data/lib/archsight/web/public/vue/treemap-SAJKECNS-DeQxu_HM.js +1 -0
  150. data/lib/archsight/web/public/vue/{useGraphviz-DlSnFeCL.js → useGraphviz-Dnm2pkon.js} +2 -2
  151. data/lib/archsight/web/public/vue/useHighlight-BH32C7yt.js +10 -0
  152. data/lib/archsight/web/public/vue/usePanZoom-HxgEsqq2.js +12 -0
  153. data/lib/archsight/web/public/vue/wardley-7MLQ67FV-BvKIB6Tv.js +1 -0
  154. data/lib/archsight/web/public/vue.html +5 -5
  155. metadata +68 -41
  156. data/lib/archsight/web/public/vue/ApiDocsPage-BgqnQgwa.js +0 -1
  157. data/lib/archsight/web/public/vue/ApiDocsPage-DZ0fa5-h.css +0 -1
  158. data/lib/archsight/web/public/vue/DocPage-CNO71nBH.js +0 -1
  159. data/lib/archsight/web/public/vue/EditorPage-DR0FCNTz.js +0 -35
  160. data/lib/archsight/web/public/vue/ErrorPage-BfYArv6s.js +0 -2
  161. data/lib/archsight/web/public/vue/GraphView-BVTW30Ak.js +0 -1
  162. data/lib/archsight/web/public/vue/HomePage-Wk9P4Pma.js +0 -2
  163. data/lib/archsight/web/public/vue/InstanceRouter-BV8ycydz.js +0 -1
  164. data/lib/archsight/web/public/vue/InstanceRouter-jeavM6PW.css +0 -1
  165. data/lib/archsight/web/public/vue/KindList-C8yMsN5J.js +0 -1
  166. data/lib/archsight/web/public/vue/PageView-BaN6TyJB.js +0 -1
  167. data/lib/archsight/web/public/vue/QueryError-D790wH-a.js +0 -1
  168. data/lib/archsight/web/public/vue/ResourceList-B0FLaFR3.css +0 -1
  169. data/lib/archsight/web/public/vue/ResourceList-D-66nas2.js +0 -2
  170. data/lib/archsight/web/public/vue/SearchResults-CHyqIerQ.js +0 -1
  171. data/lib/archsight/web/public/vue/WikiPage-D2svpk6B.js +0 -13
  172. data/lib/archsight/web/public/vue/WikiPage-DoFJ0dbW.css +0 -1
  173. data/lib/archsight/web/public/vue/architecture-7GRP2DOG-BfA1TPxQ.js +0 -1
  174. data/lib/archsight/web/public/vue/cynefin-OW5HDTMX-BmgdUKVD.js +0 -1
  175. data/lib/archsight/web/public/vue/eventmodeling-NTZA5JFV-DbXDvAWF.js +0 -1
  176. data/lib/archsight/web/public/vue/gitGraph-4MIJSDKK-BkVhrKS1.js +0 -1
  177. data/lib/archsight/web/public/vue/graphviz-SXWTgZH5.js +0 -3
  178. data/lib/archsight/web/public/vue/index-BXXkUK1V.js +0 -3
  179. data/lib/archsight/web/public/vue/index-Cov1SnzY.css +0 -1
  180. data/lib/archsight/web/public/vue/info-A6RAGUB7-BIlCVuUY.js +0 -1
  181. data/lib/archsight/web/public/vue/katex-CXMH3UgJ.js +0 -257
  182. data/lib/archsight/web/public/vue/mermaid-BjEi5URd.js +0 -3334
  183. data/lib/archsight/web/public/vue/packet-AYTQ26CC-eoQTjY3j.js +0 -1
  184. data/lib/archsight/web/public/vue/pie-WAS4IAKB-DEOotGhh.js +0 -1
  185. data/lib/archsight/web/public/vue/radar-RG4KPBEZ-C2yucuUN.js +0 -1
  186. data/lib/archsight/web/public/vue/railroad-74A4TZTK-CzONK5VY.js +0 -1
  187. data/lib/archsight/web/public/vue/railroad-abnf-HS5TGJTU-9zRPsDsx.js +0 -1
  188. data/lib/archsight/web/public/vue/railroad-ebnf-LZEXJU2U-BJ4dkz9l.js +0 -1
  189. data/lib/archsight/web/public/vue/railroad-peg-WCYAUIDC-DFy_rqAj.js +0 -1
  190. data/lib/archsight/web/public/vue/treeView-Q6P3EWNA-2cq5CyD2.js +0 -1
  191. data/lib/archsight/web/public/vue/treemap-WGGIJYW6-DK-e9Ez4.js +0 -1
  192. data/lib/archsight/web/public/vue/useHighlight-D5Gf8DLl.js +0 -10
  193. data/lib/archsight/web/public/vue/wardley-WFR3VGLG-BQwqUqfB.js +0 -1
@@ -35,6 +35,11 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
35
35
  []
36
36
  end
37
37
 
38
+ # Namespaces of the machine tags (namespace:value) that importers write into architecture/tags.
39
+ # `entrypoint` is no longer written (see component/entrypoints) but stays listed so a re-import
40
+ # removes the tags of earlier runs.
41
+ MACHINE_TAG_NAMESPACES = %w[ecosystem packaging entrypoint linkage].freeze
42
+
38
43
  PALETTE = [
39
44
  { fill: "#ddeeff", edge: "#2266cc" },
40
45
  { fill: "#ddffd8", edge: "#2a8a1e" },
@@ -51,7 +56,13 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
51
56
  def execute
52
57
  @path = config_path("path")
53
58
  raise "Missing required config: path" unless @path
54
- raise "Directory not found: #{@path}" unless File.directory?(@path)
59
+
60
+ unless File.directory?(@path)
61
+ # The clone is the repository import's job (it reports its own failure);
62
+ # no marker is written, so this runs again once the clone exists.
63
+ progress.warn("Skipping, repository not cloned: #{@path}")
64
+ return
65
+ end
55
66
 
56
67
  # Consolidate all grapher output into a single shared file unless an
57
68
  # explicit output path is already set (e.g. via grapherOutputPath on the
@@ -123,6 +134,34 @@ class Archsight::Import::Handlers::Grapher < Archsight::Import::Handler
123
134
  raise NotImplementedError, "#{self.class}#collect_packages must be implemented"
124
135
  end
125
136
 
137
+ # Hook: subclasses that emit ApplicationComponents describe each module here.
138
+ # @param _repo_root [String] Absolute path to the repository root
139
+ # @param _rel_dir [String] Directory of the module relative to the root ("." for the root)
140
+ # @param _mod_name [String] Module name from discover_modules
141
+ # @return [Hash] { type: "executable|library|module|...", tags: ["ecosystem:go", ...],
142
+ # entrypoints: ["cmd/api", ...] }, empty when unknown
143
+ def component_facts(_repo_root, _rel_dir, _mod_name)
144
+ {}
145
+ end
146
+
147
+ # component/type, component/entrypoints and the machine tags (namespace:value) in architecture/tags for the
148
+ # facts of component_facts.
149
+ # The tags a person set stay; machine tags of the importer's namespaces from an earlier run are replaced.
150
+ def component_annotations(facts, existing_tags: nil)
151
+ annotations = {}
152
+ annotations["component/type"] = facts[:type] if facts[:type]
153
+ annotations["component/entrypoints"] = facts[:entrypoints].join(",") if facts[:entrypoints]&.any?
154
+ annotations["architecture/tags"] = merge_machine_tags(existing_tags, facts[:tags]).join(",") if facts[:tags]&.any?
155
+ annotations
156
+ end
157
+
158
+ def merge_machine_tags(existing_tags, machine_tags)
159
+ kept = existing_tags.to_s.split(",").map(&:strip).reject(&:empty?).reject do |tag|
160
+ MACHINE_TAG_NAMESPACES.include?(tag.split(":", 2).first) && tag.include?(":")
161
+ end
162
+ (kept + Array(machine_tags)).uniq
163
+ end
164
+
126
165
  # ── Module coloring ───────────────────────────────────────────────────────
127
166
 
128
167
  def build_module_colors(modules)
@@ -22,6 +22,9 @@ require_relative "../team_matcher"
22
22
  # import/config/corporateAffixes - Optional comma-separated corporate username affixes for team matching (e.g., "ionos,1and1")
23
23
  # import/config/grapherOutputPath - Optional output path for language-grapher child imports
24
24
  class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
25
+ # generates child imports (see Handler.generates_imports?)
26
+ def self.generates_imports? = true
27
+
25
28
  def execute
26
29
  @path = config_path("path")
27
30
  @git_url = config("gitUrl")
@@ -43,6 +43,9 @@ require_relative "../registry"
43
43
  # ]
44
44
  # }
45
45
  class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
46
+ # generates child imports (see Handler.generates_imports?)
47
+ def self.generates_imports? = true
48
+
46
49
  VALID_GATES = %w[General-Availability Early-Access Development GA EA DEV].freeze
47
50
  GATE_TOKEN_RE = /\b(ga|ea|dev)\b/i
48
51
 
@@ -8,13 +8,25 @@ require "archsight/import"
8
8
  # The executor looks up handlers by name to instantiate and execute them.
9
9
  module Archsight::Import::Registry
10
10
  @handlers = {}
11
+ @builtin = {}
11
12
 
12
13
  class << self
13
14
  # Register a handler class with a name
14
15
  # @param name [String, Symbol] Handler name (matches import/handler annotation)
15
16
  # @param handler_class [Class] Handler class that extends Handler
17
+ # @raise [DuplicateHandlerError] if another class already registered the name
16
18
  def register(name, handler_class)
17
- @handlers[name.to_s] = handler_class
19
+ name = name.to_s
20
+ existing = @handlers[name] || @builtin[name]
21
+ raise Archsight::Import::DuplicateHandlerError, "Import handler '#{name}' is registered twice (#{existing} and #{handler_class})" if existing && existing != handler_class
22
+
23
+ @handlers[name] = handler_class
24
+ @builtin[name] = handler_class if builtin_caller?(caller_locations(1, 1)&.first)
25
+ end
26
+
27
+ # Register the built-in handlers again after `clear!` (they are only required once per process)
28
+ def restore_builtin!
29
+ @handlers = @builtin.merge(@handlers)
18
30
  end
19
31
 
20
32
  # Look up a handler class by name
@@ -70,8 +82,17 @@ module Archsight::Import::Registry
70
82
  def clear!
71
83
  @handlers = {}
72
84
  end
85
+
86
+ private
87
+
88
+ def builtin_caller?(location)
89
+ location && File.expand_path(location.path).start_with?("#{File.expand_path("handlers", __dir__)}/")
90
+ end
73
91
  end
74
92
  end
75
93
 
76
94
  # Error raised when an unknown handler is requested
77
95
  class Archsight::Import::UnknownHandlerError < StandardError; end
96
+
97
+ # Error raised when two handler classes register the same name
98
+ class Archsight::Import::DuplicateHandlerError < StandardError; end
@@ -1,14 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  # Define import module namespaces
4
6
  module Archsight
5
7
  module Import
6
8
  module Handlers
7
9
  end
10
+
11
+ # Where the git-based handlers clone repositories: the handler's own `cacheRoot` setting, else `import.cache_root`
12
+ # of the user configuration (or ARCHSIGHT_IMPORT_CACHE_ROOT), else ~/.cache/archsight/git
13
+ # @param override [String, nil] a handler's own setting
14
+ # @return [String] the directory as configured (a `~` stays, so generated child imports keep the portable form;
15
+ # expand it before use)
16
+ def self.cache_root(override = nil)
17
+ require "archsight/user_config"
18
+ override || Archsight::UserConfig.setting("import", "cache_root")&.to_s || File.join(cache_dir, "git")
19
+ end
20
+
21
+ # The cache directory of Archsight, ~/.cache/archsight
22
+ def self.cache_dir = File.join(Dir.home, ".cache", "archsight")
23
+
24
+ # Tells Spotlight (macOS) not to index this
25
+ NEVER_INDEX = ".metadata_never_index"
26
+
27
+ # Makes sure the clones are not indexed by Spotlight: the marker file in ~/.cache/archsight, or in the cache root
28
+ # itself when it is somewhere else. Run before every import; a directory that cannot be written is left alone,
29
+ # the import does not depend on it.
30
+ # @param root [String] the cache root (Handler#cache_root)
31
+ # @return [String, nil] the marker file, nil if it could not be written
32
+ def self.never_index(root)
33
+ dir = File.expand_path(root)
34
+ dir = cache_dir if dir.start_with?("#{cache_dir}/")
35
+ marker = File.join(dir, NEVER_INDEX)
36
+ return marker if File.exist?(marker)
37
+
38
+ FileUtils.mkdir_p(dir)
39
+ FileUtils.touch(marker)
40
+ marker
41
+ rescue SystemCallError
42
+ nil
43
+ end
44
+
45
+ # The entries of `import/dependsOn`: import names and glob patterns
46
+ # @return [Array<String>]
47
+ def self.declared_dependencies(import)
48
+ import.annotations["import/dependsOn"].to_s.split(",").map(&:strip).reject(&:empty?)
49
+ end
50
+
51
+ # Absolute path of a file below the resources directory, nil when the path leaves it
52
+ def self.resources_path(resources_dir, rel_path)
53
+ root = File.expand_path(resources_dir)
54
+ path = File.expand_path(rel_path.to_s, root)
55
+ path.start_with?("#{root}/") ? path : nil
56
+ end
57
+
58
+ # The file an import writes its resources to, relative to the resources directory: `import/outputPath`, else
59
+ # generated/<name>.yaml (see Handler#write_yaml)
60
+ def self.output_path(import)
61
+ import.annotations["import/outputPath"] || File.join("generated", "#{import.name.gsub(/[^a-zA-Z0-9_-]/, "_")}.yaml")
62
+ end
63
+
64
+ # The files an import wrote, relative to the resources directory: its output path and the `generated/outputs`
65
+ # of its last run. Tells which import a resource of a generated file comes from.
66
+ def self.output_files(import)
67
+ [output_path(import), *import.annotations["generated/outputs"].to_s.split(",").map(&:strip)].reject(&:empty?).uniq
68
+ end
69
+
70
+ # Hash of the files `import/config/inputs` matches (glob patterns relative to the resources directory, comma
71
+ # separated): their paths, sizes and modification times. nil when the import declares no inputs.
72
+ # @return [String, nil]
73
+ def self.inputs_hash(import, resources_dir)
74
+ patterns = import.annotations["import/config/inputs"].to_s.split(",").map(&:strip).reject(&:empty?)
75
+ return nil if patterns.empty?
76
+
77
+ root = File.expand_path(resources_dir)
78
+ files = patterns.flat_map { |pattern| Dir.glob(pattern, base: root) }.uniq.sort
79
+ stats = files.filter_map do |file|
80
+ stat = File.stat(File.join(root, file))
81
+ "#{file}:#{stat.size}:#{stat.mtime.to_f}" if stat.file?
82
+ end
83
+ require "digest"
84
+ Digest::SHA256.hexdigest(stats.join("\n"))[0, 16]
85
+ end
86
+
87
+ # Whether a `import/dependsOn` entry is a glob pattern (`Import:Repo:*`)
88
+ def self.pattern?(entry)
89
+ entry.match?(/[*?\[]/)
90
+ end
91
+
92
+ # Whether an import name matches a `import/dependsOn` entry
93
+ def self.dependency_match?(entry, name)
94
+ pattern?(entry) ? File.fnmatch?(entry, name, File::FNM_EXTGLOB) : entry == name
95
+ end
8
96
  end
9
97
  end
10
98
 
11
99
  require_relative "import/registry"
12
100
  require_relative "import/handler"
13
101
  require_relative "import/executor"
14
- # Handlers are loaded by CLI's require_import_handlers method
102
+ require_relative "import/handler_loader"
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ class Linter
5
+ # Structural checks over the declared relations, the rules of "Choosing the Kind" and "Choosing the Kind:
6
+ # Infrastructure" in the modeling guide. All findings are warnings: they point at modeling decisions to review and
7
+ # have legitimate exceptions (internal services, capabilities that no product carries yet).
8
+ class Structure
9
+ # Network types that run over another network and need one that serves them
10
+ OVERLAY_TYPES = %w[overlay vpn].freeze
11
+ NETWORK_DEVICE_TYPES = %w[switch router].freeze
12
+
13
+ def initialize(database)
14
+ @database = database
15
+ end
16
+
17
+ # Findings in a stable order, each starting with the path reference of the resource
18
+ def warnings
19
+ [
20
+ component_in_several_services,
21
+ artifact_in_several_components,
22
+ service_with_same_named_component,
23
+ product_without_service,
24
+ service_reached_from_nothing,
25
+ capability_without_product,
26
+ generated_component_in_several_repositories,
27
+ link_not_between_two_facilities,
28
+ overlay_without_underlay,
29
+ facility_without_location,
30
+ equipment_without_facility,
31
+ device_without_facility,
32
+ device_connected_to_itself,
33
+ network_device_realizing_no_network,
34
+ device_served_apart_from_its_equipment
35
+ ].flatten.sort
36
+ end
37
+
38
+ private
39
+
40
+ def instances(kind)
41
+ @database.instances_by_kind(kind).values
42
+ end
43
+
44
+ def targets(instance, verb, key)
45
+ instance.relations(verb, key)
46
+ end
47
+
48
+ def warning(instance, message)
49
+ "#{instance.path_ref}: #{instance.klass} '#{instance.name}' #{message}"
50
+ end
51
+
52
+ def names(list)
53
+ list.map { |item| "'#{item.name}'" }.join(", ")
54
+ end
55
+
56
+ # { target => [owners that list it under verb/key] } for the instances of `kind`
57
+ def owners_by_target(kind, verb, key)
58
+ owners = Hash.new { |hash, target| hash[target] = [] }
59
+ instances(kind).each { |owner| targets(owner, verb, key).each { |target| owners[target] << owner } }
60
+ owners
61
+ end
62
+
63
+ def component_in_several_services
64
+ shared("ApplicationService", :realizedThrough, :applicationComponents, "services")
65
+ end
66
+
67
+ def artifact_in_several_components
68
+ shared("ApplicationComponent", :realizedThrough, :technologyArtifacts, "components")
69
+ end
70
+
71
+ def shared(kind, verb, key, plural)
72
+ owners_by_target(kind, verb, key).filter_map do |target, owners|
73
+ next if owners.length < 2
74
+
75
+ warning(target, "is realized by #{owners.length} #{plural}: #{names(owners.sort_by(&:name))}")
76
+ end
77
+ end
78
+
79
+ def service_with_same_named_component
80
+ instances("ApplicationService").filter_map do |service|
81
+ components = targets(service, :realizedThrough, :applicationComponents)
82
+ next unless components.length == 1 && components.first.name == service.name
83
+
84
+ warning(service, "has one component with the same name")
85
+ end
86
+ end
87
+
88
+ def product_without_service
89
+ instances("BusinessProduct").filter_map do |product|
90
+ next unless targets(product, :servedBy, :applicationServices).empty?
91
+
92
+ warning(product, "is served by no ApplicationService")
93
+ end
94
+ end
95
+
96
+ # A service is reached when a product or process is served by it, directly or through services that depend on it
97
+ def service_reached_from_nothing
98
+ reached = reached_services
99
+ instances("ApplicationService").filter_map do |service|
100
+ next if reached.include?(service)
101
+
102
+ warning(service, "is not served to any product or process")
103
+ end
104
+ end
105
+
106
+ def reached_services
107
+ reached = []
108
+ queue = (instances("BusinessProduct") + instances("BusinessProcess")).flat_map { |item| targets(item, :servedBy, :applicationServices) }
109
+ until queue.empty?
110
+ service = queue.shift
111
+ next if reached.include?(service)
112
+
113
+ reached << service
114
+ queue.concat(targets(service, :dependsOn, :applicationServices))
115
+ end
116
+ reached
117
+ end
118
+
119
+ def capability_without_product
120
+ realized = owners_by_target("BusinessProduct", :realizes, :strategyCapabilities)
121
+ instances("StrategyCapability").filter_map do |capability|
122
+ next if realized.key?(capability)
123
+
124
+ warning(capability, "is realized by no product")
125
+ end
126
+ end
127
+
128
+ # A generated component realized by several repositories is two components merged into one: the repositories
129
+ # declare the same module path. A re-import gives each its own name (GoModuleParser#owned_component_name).
130
+ def generated_component_in_several_repositories
131
+ instances("ApplicationComponent").filter_map do |component|
132
+ next unless component.annotations.key?("generated/script")
133
+
134
+ repositories = targets(component, :realizedThrough, :technologyArtifacts).select { |a| a.name.start_with?("Repo:") }
135
+ next if repositories.length < 2
136
+
137
+ warning(component, "is generated from #{repositories.length} repositories with the same module path: " \
138
+ "#{names(repositories.sort_by(&:name))}")
139
+ end
140
+ end
141
+
142
+ # ---- infrastructure
143
+
144
+ def link_not_between_two_facilities
145
+ instances("TechnologyEquipment").filter_map do |equipment|
146
+ next unless equipment.annotations["equipment/type"] == "link"
147
+
148
+ count = targets(equipment, :locatedAt, :technologyFacilities).length
149
+ next if count == 2
150
+
151
+ warning(equipment, "is a link located at #{count} #{count == 1 ? "facility" : "facilities"}, not 2")
152
+ end
153
+ end
154
+
155
+ def overlay_without_underlay
156
+ instances("TechnologyCommunicationNetwork").filter_map do |network|
157
+ type = network.annotations["network/type"]
158
+ next unless OVERLAY_TYPES.include?(type)
159
+ next unless targets(network, :servedBy, :technologyCommunicationNetworks).empty?
160
+
161
+ warning(network, "is #{type == "overlay" ? "an" : "a"} #{type} network served by no network")
162
+ end
163
+ end
164
+
165
+ # Only the outermost facility needs a location; nested halls inherit it from their campus
166
+ def facility_without_location
167
+ nested = owners_by_target("TechnologyFacility", :composedOf, :technologyFacilities)
168
+ instances("TechnologyFacility").filter_map do |facility|
169
+ next if nested.key?(facility) || targets(facility, :locatedAt, :locations).any?
170
+
171
+ warning(facility, "is located at no Location")
172
+ end
173
+ end
174
+
175
+ # TechnologyEquipment that is part of other equipment is placed through its whole, and a whole (a backbone) through its
176
+ # parts (the links)
177
+ def equipment_without_facility
178
+ parts = owners_by_target("TechnologyEquipment", :composedOf, :technologyEquipment)
179
+ instances("TechnologyEquipment").filter_map do |equipment|
180
+ next if parts.key?(equipment) || targets(equipment, :composedOf, :technologyEquipment).any?
181
+ next if targets(equipment, :locatedAt, :technologyFacilities).any?
182
+
183
+ warning(equipment, "is located at no TechnologyFacility")
184
+ end
185
+ end
186
+
187
+ # A device that is part of equipment (a rack) or of another device (a virtual chassis) is placed through its
188
+ # whole, and a chassis through its members
189
+ def device_without_facility
190
+ parts = owners_by_target("TechnologyDevice", :composedOf, :technologyDevices)
191
+ racked = owners_by_target("TechnologyEquipment", :composedOf, :technologyDevices)
192
+ instances("TechnologyDevice").filter_map do |device|
193
+ next if parts.key?(device) || racked.key?(device) || targets(device, :composedOf, :technologyDevices).any?
194
+ next if targets(device, :locatedAt, :technologyFacilities).any?
195
+
196
+ warning(device, "is located at no TechnologyFacility")
197
+ end
198
+ end
199
+
200
+ def device_connected_to_itself
201
+ instances("TechnologyDevice").filter_map do |device|
202
+ next unless targets(device, :connectedTo, :technologyDevices).include?(device)
203
+
204
+ warning(device, "is connectedTo itself")
205
+ end
206
+ end
207
+
208
+ # The members of a virtual chassis or stack realize the network through the chassis
209
+ def network_device_realizing_no_network
210
+ realized = owners_by_target("TechnologyCommunicationNetwork", :realizedBy, :technologyDevices)
211
+ members = owners_by_target("TechnologyDevice", :composedOf, :technologyDevices)
212
+ instances("TechnologyDevice").filter_map do |device|
213
+ type = device.annotations["device/type"]
214
+ next unless NETWORK_DEVICE_TYPES.include?(type)
215
+ next if realized.key?(device) || members.key?(device)
216
+
217
+ warning(device, "is a #{type} that realizes no TechnologyCommunicationNetwork")
218
+ end
219
+ end
220
+
221
+ # A device in a partition (equipment servedBy a team) serves that team; one that names another team has been
222
+ # handed over without moving it. A device that names no team is served through its equipment.
223
+ def device_served_apart_from_its_equipment
224
+ holders = owners_by_target("TechnologyEquipment", :composedOf, :technologyDevices)
225
+ instances("TechnologyDevice").filter_map do |device|
226
+ teams = targets(device, :servedBy, :businessActors)
227
+ equipment_teams = holders.fetch(device, []).flat_map { |equipment| targets(equipment, :servedBy, :businessActors) }
228
+ next if teams.empty? || equipment_teams.empty?
229
+
230
+ others = teams - equipment_teams
231
+ next if others.empty?
232
+
233
+ warning(device, "is servedBy #{names(others.sort_by(&:name))}, not by the team of its equipment " \
234
+ "(#{names(equipment_teams.uniq.sort_by(&:name))})")
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end