woods 1.5.0 → 1.6.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +171 -0
  3. data/CONTRIBUTING.md +30 -1
  4. data/README.md +31 -0
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +153 -32
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/index_artifact.rb +5 -1
  49. data/lib/woods/mcp/server.rb +68 -15
  50. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  51. data/lib/woods/model_name_cache.rb +6 -1
  52. data/lib/woods/notion/client.rb +4 -1
  53. data/lib/woods/notion/exporter.rb +7 -1
  54. data/lib/woods/obsidian/errors.rb +11 -0
  55. data/lib/woods/obsidian/name_mapper.rb +132 -0
  56. data/lib/woods/obsidian/note_builder.rb +260 -0
  57. data/lib/woods/obsidian/vault_assets.rb +104 -0
  58. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  59. data/lib/woods/operator/error_escalator.rb +15 -4
  60. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  61. data/lib/woods/resolved_config.rb +4 -1
  62. data/lib/woods/retrieval/ranker.rb +45 -7
  63. data/lib/woods/retry_after.rb +36 -0
  64. data/lib/woods/unblocked/client.rb +4 -1
  65. data/lib/woods/unblocked/document_builder.rb +21 -27
  66. data/lib/woods/update_check.rb +190 -0
  67. data/lib/woods/version.rb +1 -1
  68. data/lib/woods.rb +24 -0
  69. metadata +12 -2
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'yaml'
5
+ require 'woods/export/unit_facts'
6
+
7
+ module Woods
8
+ module Obsidian
9
+ # Renders one extracted unit into a single Obsidian note: flat YAML
10
+ # frontmatter (the machine-queryable surface) followed by a Markdown body
11
+ # with wikilinks (the human surface).
12
+ #
13
+ # Two invariants from the design:
14
+ # - **Edges come from the graph, not the unit.** The authoritative "Depends
15
+ # on" / "Used by" sections are rendered from +depends_on+/+used_by+ passed
16
+ # in by the exporter (derived from the dependency graph). The "Associations"
17
+ # section is an explicit, human-only nicety read from unit metadata and is
18
+ # not part of the machine edge contract.
19
+ # - **Frontmatter is flat.** Obsidian's Properties UI cannot represent nested
20
+ # objects, so only scalars and flat lists go in YAML; structured edges live
21
+ # in the +_woods/+ sidecar. Frontmatter is emitted via Psych so any
22
+ # identifier (quotes, colons, hashes, newlines) is escaped correctly.
23
+ class NoteBuilder
24
+ # Cap on how many "Used by" links a single note renders before summarizing,
25
+ # so a hub with hundreds of dependents doesn't dominate the note or graph.
26
+ USED_BY_DISPLAY_CAP = 50
27
+
28
+ ASSOCIATION_ORDER = %w[belongs_to has_one has_many has_and_belongs_to_many].freeze
29
+
30
+ # @param name_mapper [NameMapper] resolves ids to wikilinks/paths
31
+ # @param nodes [Hash] graph nodes ({ id => { 'type' => , ... } }) for edge-endpoint types
32
+ # @param pagerank [Hash{String=>Float}] persisted pagerank scores
33
+ # @param analysis [Hash, nil] parsed graph_analysis.json (hubs/cycles/orphans/bridges) or nil
34
+ # @param include_source [Boolean] embed scrubbed source code
35
+ # @param scanner [#scan, nil] credential scanner (required when include_source)
36
+ def initialize(name_mapper:, nodes:, pagerank: {}, analysis: nil, include_source: false, scanner: nil)
37
+ @mapper = name_mapper
38
+ @nodes = nodes || {}
39
+ @pagerank = pagerank || {}
40
+ @include_source = include_source
41
+ @scanner = scanner
42
+ @hubs = identifier_set(analysis, 'hubs')
43
+ @bridges = identifier_set(analysis, 'bridges')
44
+ @orphans = plain_set(analysis, 'orphans')
45
+ @cycle_members = cycle_member_set(analysis)
46
+ end
47
+
48
+ # @param id [String] bare unit identifier (graph node key)
49
+ # @param unit [Hash] parsed unit JSON (used only for body facts)
50
+ # @param depends_on [Array<Hash>] [{ target:, via: }] filtered to the emitted set
51
+ # @param used_by [Array<String>] unique dependent ids filtered to the emitted set
52
+ # @return [String] the rendered note. A failed source scrub omits the source
53
+ # section (the note is still written); build never returns nil.
54
+ def build(id:, unit:, depends_on:, used_by:)
55
+ sections = [
56
+ frontmatter(id, unit, depends_on, used_by),
57
+ heading(id, unit),
58
+ meta_line(unit),
59
+ callout(id, used_by),
60
+ depends_section(depends_on),
61
+ used_by_section(used_by),
62
+ associations_section(unit),
63
+ schema_section(unit),
64
+ source_section(unit)
65
+ ].compact
66
+
67
+ "#{sections.join("\n\n")}\n"
68
+ end
69
+
70
+ private
71
+
72
+ # ── Frontmatter ──────────────────────────────────────────────────
73
+
74
+ def frontmatter(id, unit, depends_on, used_by)
75
+ fm = {
76
+ 'woods_managed' => true,
77
+ 'id' => id,
78
+ 'type' => unit['type'],
79
+ 'file' => unit['file_path'],
80
+ 'source_hash' => unit['source_hash'],
81
+ 'pagerank' => pagerank_for(id),
82
+ 'dependency_count' => depends_on.size,
83
+ 'dependent_count' => used_by.size,
84
+ 'tags' => tags_for(id, unit),
85
+ 'aliases' => [id]
86
+ }.compact
87
+ "#{fm.to_yaml}---"
88
+ end
89
+
90
+ def pagerank_for(id)
91
+ score = @pagerank[id]
92
+ score&.round(6)
93
+ end
94
+
95
+ def tags_for(id, unit)
96
+ tags = ['woods/unit', "woods/#{unit['type']}"]
97
+ tags << 'woods/hub' if @hubs.include?(id)
98
+ tags << 'woods/orphan' if @orphans.include?(id)
99
+ tags << 'woods/cycle' if @cycle_members.include?(id)
100
+ tags << 'woods/bridge' if @bridges.include?(id)
101
+ tags
102
+ end
103
+
104
+ # ── Body ─────────────────────────────────────────────────────────
105
+
106
+ def heading(id, unit)
107
+ "# #{unit['identifier'] || id}"
108
+ end
109
+
110
+ def meta_line(unit)
111
+ meta = unit['metadata'] || {}
112
+ parts = []
113
+ parts << "**File:** `#{unit['file_path']}`" if unit['file_path']
114
+ parts << "**LOC:** #{meta['loc']}" if meta['loc']
115
+ parts << table_part(meta) if meta['table_name']
116
+ parts.empty? ? nil : parts.join(' | ')
117
+ end
118
+
119
+ def table_part(meta)
120
+ cols = meta['column_count'] || (meta['columns'] || []).size
121
+ part = "**Table:** #{meta['table_name']}"
122
+ part += " (#{cols} columns)" if cols.is_a?(Integer) && cols.positive?
123
+ part
124
+ end
125
+
126
+ def callout(id, used_by)
127
+ if @hubs.include?(id)
128
+ pr = pagerank_for(id)
129
+ suffix = pr ? " (PageRank #{format('%.4f', pr)})" : ''
130
+ "> [!warning] Hub — high blast radius\n> #{used_by.size} units depend on this#{suffix}."
131
+ elsif @cycle_members.include?(id)
132
+ "> [!warning] Part of a dependency cycle\n> This unit participates in a circular dependency."
133
+ end
134
+ end
135
+
136
+ def depends_section(depends_on)
137
+ rows = depends_on.filter_map do |dep|
138
+ target = dep[:target] || dep['target']
139
+ via = dep[:via] || dep['via']
140
+ link = @mapper.wikilink(target)
141
+ next unless link
142
+
143
+ [target.to_s, via.to_s, "- #{link}#{" — *#{via}*" if via}"]
144
+ end
145
+ return nil if rows.empty?
146
+
147
+ lines = rows.sort_by { |target, via, _| [target, via] }.map(&:last)
148
+ (['## Depends on'] + lines).join("\n")
149
+ end
150
+
151
+ def used_by_section(used_by)
152
+ return nil if used_by.empty?
153
+
154
+ shown = used_by.sort.first(USED_BY_DISPLAY_CAP)
155
+ lines = ["## Used by (#{used_by.size})"]
156
+ shown.group_by { |sid| @nodes.dig(sid, 'type') || 'other' }.sort.each do |type, sids|
157
+ links = sids.sort.filter_map { |sid| @mapper.wikilink(sid) }
158
+ lines << "**#{pluralize(type)}:** #{links.join(', ')}" unless links.empty?
159
+ end
160
+ more = used_by.size - shown.size
161
+ lines << "*…and #{more} more (see graph)*" if more.positive?
162
+ lines.join("\n")
163
+ end
164
+
165
+ def associations_section(unit)
166
+ return nil unless unit['type'] == 'model'
167
+
168
+ by_type = Woods::Export::UnitFacts.new(unit).associations_by_type
169
+ return nil if by_type.empty?
170
+
171
+ lines = ['## Associations']
172
+ ASSOCIATION_ORDER.each do |macro|
173
+ rendered = render_associations(by_type[macro])
174
+ lines << "**#{macro}:** #{rendered.join(', ')}" unless rendered.empty?
175
+ end
176
+ lines.size > 1 ? lines.join("\n") : nil
177
+ end
178
+
179
+ # +items+ are UnitFacts association entries: { target:, dependent: }.
180
+ def render_associations(items)
181
+ return [] unless items
182
+
183
+ items.filter_map do |assoc|
184
+ link = @mapper.wikilink(assoc[:target])
185
+ next unless link
186
+
187
+ assoc[:dependent] ? "#{link} (dependent: #{assoc[:dependent]})" : link
188
+ end.sort
189
+ end
190
+
191
+ # Human-readable schema highlights (model only) — enums, scopes, concerns,
192
+ # callbacks — formatted from the shared UnitFacts.
193
+ def schema_section(unit)
194
+ return nil unless unit['type'] == 'model'
195
+
196
+ highlights = Woods::Export::UnitFacts.new(unit).schema_highlights
197
+ parts = []
198
+ parts << "**Enums:** #{highlights[:enums].keys.sort.join(', ')}" if highlights[:enums].any?
199
+ parts << "**Scopes:** #{highlights[:scopes].sort.join(', ')}" if highlights[:scopes].any?
200
+ parts << "**Concerns:** #{highlights[:concerns].sort.join(', ')}" if highlights[:concerns].any?
201
+ parts << "**Callbacks:** #{format_callbacks(highlights[:callbacks])}" if highlights[:callbacks].any?
202
+
203
+ parts.empty? ? nil : (['## Schema'] + parts).join("\n")
204
+ end
205
+
206
+ def format_callbacks(callbacks)
207
+ callbacks.map { |cb| [cb[:type], cb[:filter]].compact.join(' ') }.sort.join(', ')
208
+ end
209
+
210
+ # Renders the source block when enabled. If the credential scrub fails it
211
+ # returns nil (omit the section) rather than shipping unscrubbed source —
212
+ # the note is still written, just without its source block.
213
+ def source_section(unit)
214
+ return nil unless @include_source
215
+
216
+ code = unit['source_code']
217
+ return nil if code.nil? || code.to_s.empty?
218
+
219
+ scrubbed = scrub(code)
220
+ return nil if scrubbed.nil?
221
+
222
+ "## Source\n\n```ruby\n#{scrubbed.chomp}\n```"
223
+ end
224
+
225
+ def scrub(code)
226
+ return code unless @scanner
227
+
228
+ scanned, = @scanner.scan(code)
229
+ scanned
230
+ rescue StandardError
231
+ nil
232
+ end
233
+
234
+ # ── Analysis sets ────────────────────────────────────────────────
235
+
236
+ def identifier_set(analysis, key)
237
+ entries = analysis && analysis[key]
238
+ return Set.new unless entries.is_a?(Array)
239
+
240
+ entries.filter_map { |e| e.is_a?(Hash) ? e['identifier'] : e }.to_set
241
+ end
242
+
243
+ def plain_set(analysis, key)
244
+ entries = analysis && analysis[key]
245
+ entries.is_a?(Array) ? entries.to_set : Set.new
246
+ end
247
+
248
+ def cycle_member_set(analysis)
249
+ cycles = analysis && analysis['cycles']
250
+ return Set.new unless cycles.is_a?(Array)
251
+
252
+ cycles.flatten.to_set
253
+ end
254
+
255
+ def pluralize(type)
256
+ type.end_with?('y') ? "#{type[0..-2]}ies" : "#{type}s"
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Obsidian
7
+ # Generates the static-ish vault configuration files: the `.obsidian/`
8
+ # settings Obsidian reads (app.json, types.json, graph.json) and the
9
+ # `Units.base` Obsidian Bases view.
10
+ #
11
+ # Grounded in Steph Ango's published vault conventions and the official
12
+ # Obsidian docs:
13
+ # - `app.json` only governs links Obsidian *generates*; our authored
14
+ # `[[path|alias]]` links resolve regardless. We still set absolute-path
15
+ # wikilinks as a courtesy for the user's future edits.
16
+ # - We deliberately do NOT ship `core-plugins.json` — graph and Bases are
17
+ # enabled by default, and a partial plugin list could disable other
18
+ # defaults.
19
+ # - `types.json` types the properties so Bases columns sort numerically
20
+ # (pagerank/counts) and tags render as tags.
21
+ # - `graph.json` color groups apply to the *global* graph only (local-graph
22
+ # colors live in volatile workspace.json, which must not be shipped).
23
+ module VaultAssets
24
+ module_function
25
+
26
+ # Palette of 24-bit RGB ints assigned to type tags in sorted order so the
27
+ # graph coloring is deterministic across runs.
28
+ PALETTE = [
29
+ 0xE5_5A_5A, 0x5A_9B_E5, 0x5A_E5_8B, 0xE5_C8_5A, 0xB4_5A_E5,
30
+ 0x5A_E5_D6, 0xE5_8B_5A, 0x8B_E5_5A, 0xE5_5A_B4, 0x5A_6B_E5
31
+ ].freeze
32
+
33
+ PROPERTY_TYPES = {
34
+ 'id' => 'text', 'type' => 'text', 'file' => 'text', 'source_hash' => 'text',
35
+ 'pagerank' => 'number', 'dependency_count' => 'number', 'dependent_count' => 'number',
36
+ 'tags' => 'tags', 'aliases' => 'aliases'
37
+ }.freeze
38
+
39
+ # @return [String] .obsidian/app.json
40
+ def app_json
41
+ pretty(
42
+ 'newLinkFormat' => 'absolute',
43
+ 'useMarkdownLinks' => false,
44
+ 'alwaysUpdateLinks' => true
45
+ )
46
+ end
47
+
48
+ # @return [String] .obsidian/types.json (property -> type registry)
49
+ def types_json
50
+ pretty('types' => PROPERTY_TYPES)
51
+ end
52
+
53
+ # @param types [Array<String>] singular unit types present in the vault
54
+ # @return [String] .obsidian/graph.json with per-type color groups
55
+ def graph_json(types)
56
+ groups = Array(types).uniq.sort.each_with_index.map do |type, i|
57
+ { 'query' => "tag:#woods/#{type}", 'color' => { 'a' => 1, 'rgb' => PALETTE[i % PALETTE.size] } }
58
+ end
59
+ pretty('colorGroups' => groups, 'showTags' => true, 'showAttachments' => false)
60
+ end
61
+
62
+ # @return [String] Units.base — an Obsidian Bases view (YAML)
63
+ def units_base
64
+ <<~YAML
65
+ filters:
66
+ and:
67
+ - 'file.hasTag("woods/unit")'
68
+ properties:
69
+ type:
70
+ displayName: Type
71
+ pagerank:
72
+ displayName: PageRank
73
+ dependent_count:
74
+ displayName: Used by
75
+ dependency_count:
76
+ displayName: Depends on
77
+ views:
78
+ - type: table
79
+ name: All units
80
+ groupBy: type
81
+ order:
82
+ - file.name
83
+ - type
84
+ - pagerank
85
+ - dependent_count
86
+ - dependency_count
87
+ - type: table
88
+ name: Hubs
89
+ filters:
90
+ and:
91
+ - 'file.hasTag("woods/hub")'
92
+ order:
93
+ - file.name
94
+ - dependent_count
95
+ - pagerank
96
+ YAML
97
+ end
98
+
99
+ def pretty(hash)
100
+ "#{JSON.pretty_generate(hash)}\n"
101
+ end
102
+ end
103
+ end
104
+ end