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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +171 -0
- data/CONTRIBUTING.md +30 -1
- data/README.md +31 -0
- data/lib/tasks/woods.rake +52 -2
- data/lib/woods/atomic_file.rb +43 -0
- data/lib/woods/cache/cache_middleware.rb +18 -1
- data/lib/woods/console/embedded_executor.rb +20 -2
- data/lib/woods/console/eval_guard.rb +8 -2
- data/lib/woods/console/sql_noise_stripper.rb +113 -0
- data/lib/woods/console/sql_table_scanner.rb +22 -9
- data/lib/woods/console/sql_validator.rb +1 -2
- data/lib/woods/coordination/pipeline_lock.rb +112 -7
- data/lib/woods/dependency_graph.rb +38 -1
- data/lib/woods/embedding/text_preparer.rb +6 -1
- data/lib/woods/export/unit_facts.rb +80 -0
- data/lib/woods/extractor.rb +153 -32
- data/lib/woods/extractors/concern_extractor.rb +3 -5
- data/lib/woods/extractors/configuration_extractor.rb +3 -5
- data/lib/woods/extractors/controller_extractor.rb +1 -1
- data/lib/woods/extractors/database_view_extractor.rb +1 -1
- data/lib/woods/extractors/decorator_extractor.rb +3 -5
- data/lib/woods/extractors/event_extractor.rb +2 -2
- data/lib/woods/extractors/factory_extractor.rb +2 -4
- data/lib/woods/extractors/graphql_extractor.rb +1 -1
- data/lib/woods/extractors/i18n_extractor.rb +6 -4
- data/lib/woods/extractors/job_extractor.rb +4 -6
- data/lib/woods/extractors/lib_extractor.rb +1 -1
- data/lib/woods/extractors/mailer_extractor.rb +1 -1
- data/lib/woods/extractors/manager_extractor.rb +3 -5
- data/lib/woods/extractors/migration_extractor.rb +1 -1
- data/lib/woods/extractors/model_extractor.rb +26 -10
- data/lib/woods/extractors/phlex_extractor.rb +1 -1
- data/lib/woods/extractors/policy_extractor.rb +3 -5
- data/lib/woods/extractors/poro_extractor.rb +1 -1
- data/lib/woods/extractors/pundit_extractor.rb +3 -5
- data/lib/woods/extractors/rake_task_extractor.rb +2 -4
- data/lib/woods/extractors/serializer_extractor.rb +4 -6
- data/lib/woods/extractors/service_extractor.rb +3 -5
- data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
- data/lib/woods/extractors/shared_utility_methods.rb +14 -0
- data/lib/woods/extractors/state_machine_extractor.rb +2 -4
- data/lib/woods/extractors/validator_extractor.rb +3 -5
- data/lib/woods/extractors/view_component_extractor.rb +1 -1
- data/lib/woods/filename_utils.rb +31 -2
- data/lib/woods/flow_assembler.rb +45 -28
- data/lib/woods/flow_precomputer.rb +2 -1
- data/lib/woods/index_artifact.rb +5 -1
- data/lib/woods/mcp/server.rb +68 -15
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
- data/lib/woods/model_name_cache.rb +6 -1
- data/lib/woods/notion/client.rb +4 -1
- data/lib/woods/notion/exporter.rb +7 -1
- data/lib/woods/obsidian/errors.rb +11 -0
- data/lib/woods/obsidian/name_mapper.rb +132 -0
- data/lib/woods/obsidian/note_builder.rb +260 -0
- data/lib/woods/obsidian/vault_assets.rb +104 -0
- data/lib/woods/obsidian/vault_exporter.rb +412 -0
- data/lib/woods/operator/error_escalator.rb +15 -4
- data/lib/woods/resilience/circuit_breaker.rb +98 -24
- data/lib/woods/resolved_config.rb +4 -1
- data/lib/woods/retrieval/ranker.rb +45 -7
- data/lib/woods/retry_after.rb +36 -0
- data/lib/woods/unblocked/client.rb +4 -1
- data/lib/woods/unblocked/document_builder.rb +21 -27
- data/lib/woods/update_check.rb +190 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +24 -0
- 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
|