archsight 0.2.7 → 0.2.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee4785cb42348dcbad1e9731b83a9e9b783f3eebe831dac544a771cff6a01829
4
- data.tar.gz: 6223d231b7a01a0eed99ec14ad7bfa5ccad45b41ff4b6e4b4a29bd64946cd184
3
+ metadata.gz: fca6df89e7a4bbcae73578f3f9bed3bf680dd86561269396a0a4243c84109eed
4
+ data.tar.gz: 3991aafe49e2f950305e91f92cf93c0b953a808647cdceb09ae01edb2df4d304
5
5
  SHA512:
6
- metadata.gz: 6c76128de7274e7dc083bae693958f8afad14231120d59c2a3ac169408962f739239c06ec3a30d2b1a34fa19fb555bbe2ee87deaec6464881faad4e992482a3c
7
- data.tar.gz: 7b6c865e1ab3287be9e8a23c29b9884cf0e98c0929af47ab577e567c51d3c075ee00b51653191267c8f0fde1032b7ba872221932850092643c5d8db1235fa130
6
+ metadata.gz: 0e2be76c13cb35664086dc9f5d96675c6c0a54dd11d10803895edac5887428bd7b5d0441c5e419a82454f3532175556c3c398615edabe86b2fe4bf62bc02599b
7
+ data.tar.gz: 42133f269b0f2b09306da83c16fb311539180814e6cec26b3e8bc4f1a4b5b35d08922175f1bf464cd00c6c86f4d7df9931dbf576d00873ca7d174be2459540fe
@@ -115,7 +115,7 @@ class Archsight::Import::Handler
115
115
  # Use thread-safe shared writer for concurrent execution
116
116
  # Default sort key is import name for stable output ordering
117
117
  key = sort_key || import_resource.name
118
- @shared_writer.append_yaml(full_path, content, sort_key: key)
118
+ @shared_writer.append_yaml(full_path, content, sort_key: key, producer: import_resource.name)
119
119
  else
120
120
  # Direct write for non-concurrent mode
121
121
  FileUtils.mkdir_p(File.dirname(full_path))
@@ -209,7 +209,8 @@ class Archsight::Import::Handler
209
209
  end
210
210
 
211
211
  if @shared_writer
212
- @shared_writer.append_yaml(full_path, YAML.dump(meta), sort_key: "#{import_resource.name}:generates")
212
+ @shared_writer.append_yaml(full_path, YAML.dump(meta), sort_key: "#{import_resource.name}:generates",
213
+ producer: import_resource.name)
213
214
  else
214
215
  # Append to existing file
215
216
  File.open(full_path, "a") { |f| f.write(YAML.dump(meta)) }
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared helper for import handlers that clone repositories into a local
4
+ # cache directory keyed by name (see github.rb, gitlab.rb). Removes cache
5
+ # subdirectories that no longer correspond to any repo/project returned by
6
+ # the current API listing, so a renamed, moved, or deleted repo doesn't
7
+ # leave a stale clone behind that a later importer could still resolve
8
+ # against under its old name.
9
+ module Archsight::Import::Handlers::CachePruner
10
+ def prune_stale_cache_entries(target_dir, current_names)
11
+ return unless File.directory?(target_dir)
12
+
13
+ Dir.children(target_dir).each do |entry|
14
+ next if current_names.include?(entry)
15
+
16
+ stale_path = File.join(target_dir, entry)
17
+ next unless File.directory?(stale_path)
18
+
19
+ progress.warn("Pruning stale cache entry: #{entry} (repo renamed, moved, or removed)")
20
+ FileUtils.rm_rf(stale_path)
21
+ end
22
+ end
23
+ end
@@ -7,6 +7,7 @@ require "uri"
7
7
  require "openssl"
8
8
  require_relative "../handler"
9
9
  require_relative "../registry"
10
+ require_relative "cache_pruner"
10
11
 
11
12
  # GitHub handler - lists repositories from a GitHub organization and generates child Import resources
12
13
  #
@@ -18,6 +19,7 @@ require_relative "../registry"
18
19
  # import/config/botTeam - Team for bot-only repositories (propagated to child imports)
19
20
  # import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
20
21
  # import/config/defaultVisibility - Default visibility for non-public repos (default: "internal")
22
+ # import/config/cacheRoot - Override for the local git cache root (default: ~/.cache/archsight/git)
21
23
  #
22
24
  # Environment:
23
25
  # GITHUB_TOKEN - GitHub Personal Access Token (required)
@@ -27,7 +29,14 @@ require_relative "../registry"
27
29
  # Output:
28
30
  # Generates Import:Repo:* resources for each repository
29
31
  # The repository handler will clone/sync the actual git repositories
32
+ #
33
+ # Before generating imports, prunes any cache subdirectory under
34
+ # cacheRoot/github/<org> that no longer corresponds to a repo returned by
35
+ # the API (e.g. a repo that was renamed or deleted), so stale clones don't
36
+ # linger and get picked up by later importers.
30
37
  class Archsight::Import::Handlers::Github < Archsight::Import::Handler
38
+ include Archsight::Import::Handlers::CachePruner
39
+
31
40
  PER_PAGE = 100
32
41
 
33
42
  def execute
@@ -40,7 +49,8 @@ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
40
49
  @repo_output_path = config("repoOutputPath")
41
50
  @child_cache_time = config("childCacheTime")
42
51
  @default_visibility = config("defaultVisibility", default: "internal")
43
- @target_dir = File.join(Dir.home, ".cache", "archsight", "git", "github", @org)
52
+ cache_root = config("cacheRoot", default: File.join(Dir.home, ".cache", "archsight", "git"))
53
+ @target_dir = File.join(cache_root, "github", @org)
44
54
 
45
55
  # Fetch all repositories with pagination
46
56
  progress.update("Fetching repositories from #{@org}")
@@ -51,6 +61,8 @@ class Archsight::Import::Handlers::Github < Archsight::Import::Handler
51
61
  return
52
62
  end
53
63
 
64
+ prune_stale_cache_entries(@target_dir, repos.map { |r| r["name"] })
65
+
54
66
  # Generate Import resources for each repository
55
67
  progress.update("Generating #{repos.size} import resources")
56
68
  generate_repository_imports(repos)
@@ -8,6 +8,7 @@ require "uri"
8
8
  require "openssl"
9
9
  require_relative "../handler"
10
10
  require_relative "../registry"
11
+ require_relative "cache_pruner"
11
12
 
12
13
  # GitLab handler - lists repositories from a GitLab instance and generates child Import resources
13
14
  #
@@ -23,6 +24,7 @@ require_relative "../registry"
23
24
  # import/config/botTeam - Team for bot-only repositories (propagated to child imports)
24
25
  # import/config/corporateAffixes - Comma-separated corporate username affixes for team matching (propagated to child imports)
25
26
  # import/config/defaultVisibility - Default visibility when API returns none (default: "internal")
27
+ # import/config/cacheRoot - Override for the local git cache root (default: ~/.cache/archsight/git)
26
28
  #
27
29
  # Environment:
28
30
  # GITLAB_TOKEN - GitLab personal access token (required)
@@ -30,7 +32,14 @@ require_relative "../registry"
30
32
  # Output:
31
33
  # Generates Import:Repo:* resources for each repository
32
34
  # The repository handler will clone/sync the actual git repositories (via SSH)
35
+ #
36
+ # Before generating imports, prunes any cache subdirectory under
37
+ # cacheRoot/gitlab that no longer corresponds to a project returned by the
38
+ # API (e.g. a project that was renamed, moved, or deleted), so stale clones
39
+ # don't linger and get picked up by later importers.
33
40
  class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
41
+ include Archsight::Import::Handlers::CachePruner
42
+
34
43
  def execute
35
44
  @host = config("host")
36
45
  raise "Missing required config: host" unless @host
@@ -42,7 +51,8 @@ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
42
51
  @child_cache_time = config("childCacheTime")
43
52
  @default_visibility = config("defaultVisibility", default: "internal")
44
53
 
45
- @target_dir = File.join(Dir.home, ".cache", "archsight", "git", "gitlab")
54
+ cache_root = config("cacheRoot", default: File.join(Dir.home, ".cache", "archsight", "git"))
55
+ @target_dir = File.join(cache_root, "gitlab")
46
56
  @explore_groups = config("exploreGroups") == "true"
47
57
  @per_page = config("perPage", default: "100").to_i
48
58
  @verify_ssl = config("verifySSL") != "false"
@@ -57,6 +67,8 @@ class Archsight::Import::Handlers::Gitlab < Archsight::Import::Handler
57
67
  return
58
68
  end
59
69
 
70
+ prune_stale_cache_entries(@target_dir, projects.map { |p| safe_dir_name(p["path_with_namespace"]) })
71
+
60
72
  # Generate Import resources for each repository
61
73
  progress.update("Generating #{projects.size} import resources")
62
74
  generate_repository_imports(projects)
@@ -33,20 +33,41 @@ module Archsight::Import::Handlers::GoModuleParser
33
33
  else
34
34
  root_mod = read_module_name(repo_root)
35
35
  modules << [".", root_mod] if root_mod
36
+ modules.concat(discover_nested_modules(repo_root, root_mod))
37
+ end
38
+
39
+ modules
40
+ end
41
+
42
+ # Scan subdirectories for additional go.mod files (multi-module monorepo
43
+ # without go.work). Skips a nested go.mod whose declared module path is
44
+ # unrelated to the repo's own root module - likely a vendored/embedded copy
45
+ # of a foreign project (e.g. a full committed snapshot of another,
46
+ # possibly since-renamed, repo) rather than an intentional submodule of
47
+ # this one - so it doesn't manufacture a cross-repo component/dependency.
48
+ #
49
+ # @return [Array<Array<String>>] List of [rel_dir, mod_name] pairs
50
+ def discover_nested_modules(repo_root, root_mod)
51
+ modules = []
52
+
53
+ Find.find(repo_root) do |path|
54
+ bn = File.basename(path)
55
+ Find.prune if File.directory?(path) && %w[vendor testdata .git node_modules].include?(bn)
56
+ next unless bn == "go.mod"
36
57
 
37
- # Also scan subdirectories for additional go.mod files (multi-module monorepo without go.work)
38
- Find.find(repo_root) do |path|
39
- bn = File.basename(path)
40
- Find.prune if File.directory?(path) && %w[vendor testdata .git node_modules].include?(bn)
41
- next unless bn == "go.mod"
58
+ mod_dir = File.dirname(path)
59
+ next if mod_dir == repo_root # already added above
42
60
 
43
- mod_dir = File.dirname(path)
44
- next if mod_dir == repo_root # already added above
61
+ rel = mod_dir.delete_prefix("#{repo_root}/")
62
+ name = read_module_name(mod_dir)
63
+ next unless name
45
64
 
46
- rel = mod_dir.delete_prefix("#{repo_root}/")
47
- name = read_module_name(mod_dir)
48
- modules << [rel, name] if name
65
+ if root_mod && !nested_under_root?(name, root_mod)
66
+ progress.warn("Skipping foreign nested go.mod at #{rel}: module #{name} is unrelated to root module #{root_mod}")
67
+ next
49
68
  end
69
+
70
+ modules << [rel, name]
50
71
  end
51
72
 
52
73
  modules
@@ -91,6 +112,15 @@ module Archsight::Import::Handlers::GoModuleParser
91
112
  paths.uniq
92
113
  end
93
114
 
115
+ # Whether a nested module's declared path is the root module itself or
116
+ # genuinely nested under it (a real monorepo submodule), as opposed to an
117
+ # unrelated/foreign module path (a vendored or embedded copy of another
118
+ # project) that happens to live in a subdirectory.
119
+ # @return [Boolean]
120
+ def nested_under_root?(mod_name, root_mod_name)
121
+ mod_name == root_mod_name || mod_name.start_with?("#{root_mod_name}/")
122
+ end
123
+
94
124
  # Return the SCM host+org prefix shared by modules in the same org, e.g. "github.com/ionos-cloud/".
95
125
  # @return [String, nil] Prefix with trailing slash, or nil for single-segment names
96
126
  def same_origin_prefix(mod_name)
@@ -227,7 +227,10 @@ class Archsight::Import::Handlers::Repository < Archsight::Import::Handler
227
227
  private
228
228
 
229
229
  def run_scc(path)
230
- cmd = ["scc", "--exclude-dir", ".git,.hg,.svn,vendor,node_modules", "-f", "json2", "--sort", "name", path]
230
+ # Plain Text files (.txt, .text) are excluded: they carry no structural code
231
+ # signal and can dominate scc's cost/effort estimates when a repo happens to
232
+ # contain large ad hoc text blobs (e.g. datasets, doc dumps).
233
+ cmd = ["scc", "--exclude-dir", ".git,.hg,.svn,vendor,node_modules", "-x", "txt,text", "-f", "json2", "--sort", "name", path]
231
234
 
232
235
  out, err, status = Open3.capture3(*cmd)
233
236
  raise "scc failed: #{cmd.join(" ")}\n#{err}" unless status.success?
@@ -18,6 +18,13 @@ require_relative "../registry"
18
18
  # import/config/skipVisibility - Comma-separated visibilities to skip (e.g., "public-preview")
19
19
  # import/config/childCacheTime - Cache time for generated child imports (e.g., "1h", "30m")
20
20
  #
21
+ # APIs whose "gate" value isn't a recognized status (General-Availability,
22
+ # Early-Access, Development, or their GA/EA/DEV aliases) are skipped, since
23
+ # they can't be represented by the ApplicationInterface status annotation.
24
+ # A gate value that embeds a recognized GA/EA/DEV token alongside extra
25
+ # segments (e.g. "ea.ionosc") is tolerated by extracting that token instead
26
+ # of being rejected outright.
27
+ #
21
28
  # Output:
22
29
  # Generates Import:RestApi:* resources for each API in the index
23
30
  #
@@ -36,6 +43,9 @@ require_relative "../registry"
36
43
  # ]
37
44
  # }
38
45
  class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
46
+ VALID_GATES = %w[General-Availability Early-Access Development GA EA DEV].freeze
47
+ GATE_TOKEN_RE = /\b(ga|ea|dev)\b/i
48
+
39
49
  def execute
40
50
  @index_url = config("indexUrl")
41
51
  raise "Missing required config: indexUrl" unless @index_url
@@ -55,10 +65,10 @@ class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
55
65
  return
56
66
  end
57
67
 
58
- # Filter APIs by visibility
68
+ # Filter APIs by visibility and gate
59
69
  original_count = apis.size
60
70
  apis = filter_apis(apis)
61
- progress.update("Filtered to #{apis.size} APIs (skipped #{original_count - apis.size} by visibility)") if apis.size < original_count
71
+ progress.update("Filtered to #{apis.size} APIs (skipped #{original_count - apis.size} by visibility/gate)") if apis.size < original_count
62
72
 
63
73
  # Generate child imports
64
74
  progress.update("Generating #{apis.size} import resources")
@@ -107,14 +117,33 @@ class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
107
117
  end
108
118
 
109
119
  def filter_apis(apis)
110
- return apis if @skip_visibilities.empty?
111
-
112
120
  apis.reject do |api|
113
121
  visibility = api["visibility"]&.downcase
114
- @skip_visibilities.any? { |skip| visibility == skip.downcase }
122
+ skipped_by_visibility = @skip_visibilities.any? { |skip| visibility == skip.downcase }
123
+ skipped_by_visibility || !valid_gate?(api["gate"])
115
124
  end
116
125
  end
117
126
 
127
+ def valid_gate?(gate)
128
+ return true if gate.nil? # falls back to "GA" default in generate_api_imports
129
+
130
+ !resolve_gate(gate).nil?
131
+ end
132
+
133
+ # Resolve a gate value to a recognized stage (a VALID_GATES entry, matched
134
+ # as given, or an extracted GA/EA/DEV token), tolerating extra trailing
135
+ # segments some index entries carry (e.g. an "ionosc" variant suffix like
136
+ # "ea.ionosc") by pulling the first recognized stage token out of an
137
+ # otherwise-unrecognized value rather than rejecting it outright.
138
+ # @return [String, nil] Resolved gate, or nil if no recognized stage found
139
+ def resolve_gate(gate)
140
+ return nil if gate.nil?
141
+ return gate if VALID_GATES.any? { |valid| gate.casecmp?(valid) }
142
+
143
+ match = GATE_TOKEN_RE.match(gate)
144
+ match && match[1].upcase
145
+ end
146
+
118
147
  def generate_api_imports(apis)
119
148
  yaml_documents = apis.map do |api|
120
149
  api_name = api["name"]
@@ -137,7 +166,7 @@ class Archsight::Import::Handlers::RestApiIndex < Archsight::Import::Handler
137
166
  "version" => api["version"] || "1.0",
138
167
  "visibility" => visibility,
139
168
  "specUrl" => spec_url,
140
- "gate" => api["gate"] || "GA"
169
+ "gate" => resolve_gate(api["gate"]) || "GA"
141
170
  }
142
171
  child_config["htmlUrl"] = html_url if html_url
143
172
 
@@ -1,16 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "yaml"
4
5
 
5
6
  # Thread-safe file writer for concurrent import handlers
6
7
  #
7
8
  # Manages shared output files that multiple handlers can write to.
8
9
  # Content is buffered in memory and sorted by key when close_all is called.
9
10
  #
11
+ # When a file is flushed, any document already on disk whose producer isn't
12
+ # among this flush's fresh producers is carried forward unchanged - this is
13
+ # what lets a cached (skipped) import's previously-generated output, or
14
+ # another task's output from an earlier iteration of the same run, survive
15
+ # being flushed to a shared output file instead of getting silently wiped.
16
+ #
10
17
  # @example
11
18
  # writer = SharedFileWriter.new
12
- # writer.append_yaml("/path/to/output.yaml", yaml_content, sort_key: "Repo:name")
13
- # writer.close_all # Sorts and writes buffered content
19
+ # writer.append_yaml("/path/to/output.yaml", yaml_content, sort_key: "Repo:name", producer: "Import:Repo:name")
20
+ # writer.close_all # Sorts and writes buffered content, merged with existing content
14
21
  class Archsight::Import::SharedFileWriter
15
22
  def initialize
16
23
  @mutex = Mutex.new
@@ -23,14 +30,17 @@ class Archsight::Import::SharedFileWriter
23
30
  # @param path [String] Full path to the output file
24
31
  # @param content [String] YAML content to append
25
32
  # @param sort_key [String, nil] Key for sorting (nil keys go last)
26
- def append_yaml(path, content, sort_key: nil)
33
+ # @param producer [String, nil] Name of the Import task that produced this
34
+ # content, used to decide what existing on-disk content this entry may
35
+ # supersede when the file is flushed
36
+ def append_yaml(path, content, sort_key: nil, producer: nil)
27
37
  @mutex.synchronize do
28
38
  @files[path] ||= { entries: [], lock: Mutex.new }
29
39
  end
30
40
 
31
41
  entry = @files[path]
32
42
  entry[:lock].synchronize do
33
- entry[:entries] << { key: sort_key, content: content }
43
+ entry[:entries] << { key: sort_key, content: content, producer: producer }
34
44
  end
35
45
  end
36
46
 
@@ -51,8 +61,11 @@ class Archsight::Import::SharedFileWriter
51
61
 
52
62
  FileUtils.mkdir_p(File.dirname(path))
53
63
 
64
+ fresh_producers = entries.filter_map { |e| e[:producer] }.uniq
65
+ all_entries = preserved_entries(path, fresh_producers) + entries
66
+
54
67
  # Sort by key (nil keys go last)
55
- sorted = entries.sort_by { |e| e[:key] || "\xFF" }
68
+ sorted = all_entries.sort_by { |e| e[:key] || "\xFF" }
56
69
 
57
70
  File.open(path, "w") do |file|
58
71
  sorted.each_with_index do |entry, idx|
@@ -64,4 +77,35 @@ class Archsight::Import::SharedFileWriter
64
77
  end
65
78
  end
66
79
  end
80
+
81
+ # Documents already on disk whose producer isn't among this flush's fresh
82
+ # producers are carried forward unchanged, keyed by their own producer so
83
+ # a producer that does write fresh content later in the same run replaces
84
+ # its own prior output rather than duplicating it. Content is kept as its
85
+ # original raw text (not re-dumped), so anything preserved is byte-for-byte
86
+ # identical to what was already on disk.
87
+ def preserved_entries(path, fresh_producers)
88
+ return [] unless File.exist?(path)
89
+
90
+ split_yaml_documents(File.read(path)).filter_map do |raw|
91
+ doc = YAML.safe_load(raw, permitted_classes: [Time])
92
+ next nil unless doc.is_a?(Hash)
93
+
94
+ producer = document_producer(doc)
95
+ next nil if producer.nil? || fresh_producers.include?(producer)
96
+
97
+ { key: producer, content: raw, producer: producer }
98
+ rescue Psych::SyntaxError
99
+ nil
100
+ end
101
+ end
102
+
103
+ def document_producer(doc)
104
+ doc.dig("metadata", "annotations", "generated/script") ||
105
+ (doc["kind"] == "Import" ? doc.dig("metadata", "name") : nil)
106
+ end
107
+
108
+ def split_yaml_documents(text)
109
+ text.split(/^---\s*$/).map(&:strip).reject(&:empty?)
110
+ end
67
111
  end
@@ -4,5 +4,5 @@
4
4
  # Do not edit manually.
5
5
 
6
6
  module Archsight
7
- VERSION = "0.2.7"
7
+ VERSION = "0.2.9"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: archsight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.7
4
+ version: 0.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vincent Landgraf
@@ -264,6 +264,7 @@ files:
264
264
  - lib/archsight/import/executor.rb
265
265
  - lib/archsight/import/git_analytics.rb
266
266
  - lib/archsight/import/handler.rb
267
+ - lib/archsight/import/handlers/cache_pruner.rb
267
268
  - lib/archsight/import/handlers/cpp_grapher.rb
268
269
  - lib/archsight/import/handlers/crystal_grapher.rb
269
270
  - lib/archsight/import/handlers/elixir_grapher.rb