okf 1.9.0 → 1.10.0

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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The tag index: which tags exist, how often, and on what. --by regroups them
6
+ # per concept dimension, which is the view for curating a vocabulary rather
7
+ # than reading one.
8
+ class Tags < Command
9
+ def self.id
10
+ :tags
11
+ end
12
+
13
+ def self.group
14
+ :read
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "tags <dir|@slug> [--json] [--by DIM] [filters]", "list tags with their concepts, by count" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ options = { json: false, by: nil }
25
+ parser = OptionParser.new do |o|
26
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
27
+ json_flags(o, options, "emit the tag index as JSON")
28
+ o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
29
+ filter_flags(o, options, :type, :area)
30
+ help_flag(o)
31
+ end
32
+ dir = positional_dir(parser, argv) or return 2
33
+
34
+ return grouped_tags(dir, options) if options[:by]
35
+
36
+ print_inverted_index(dir, "Tags", :tag, "tags", options)
37
+ end
38
+
39
+ private
40
+
41
+ # `tags --by type|area`: the tag index re-cut per concept type or top-level
42
+ # area, with within-group counts — the curation view. A tag confined to one
43
+ # group at count 1 is scattered; one recurring across groups is connective.
44
+ # The --type/--area filters narrow the concepts first, then the grouping cuts.
45
+ def grouped_tags(dir, options)
46
+ folder = OKF::Bundle::Folder.load(dir)
47
+ report_skipped(folder)
48
+ graph = folder.graph(minimal: true)
49
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
50
+ groups = tag_groups(graph.tag_index, folder, options)
51
+ options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
52
+ 0
53
+ end
54
+
55
+ # [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'
56
+ # plus each tag's total across the narrowed set. A tag carried in several
57
+ # groups appears in each, counted per group; count/total per row is what
58
+ # makes a tag's spread — local to one group, or cutting across several —
59
+ # readable without cross-referencing the groups by hand.
60
+ def tag_groups(tag_index, folder, options)
61
+ by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
62
+ groups = {}
63
+ totals = Hash.new(0)
64
+ tag_index.each do |tag, ids|
65
+ ids.each do |id|
66
+ entry = by_id[id]
67
+ next if entry.nil?
68
+
69
+ key = options[:by] == :type ? entry_type(entry) : entry[:area]
70
+ ((groups[key] ||= {})[tag] ||= []) << id
71
+ totals[tag] += 1
72
+ end
73
+ end
74
+ groups.map do |key, tags|
75
+ rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
76
+ .sort_by { |row| [ -row[:count], row[:tag] ] }
77
+ [ key, rows ]
78
+ end.sort_by(&:first)
79
+ end
80
+
81
+ # A catalog entry's type for display — "Untyped" when blank, matching the graph.
82
+ def entry_type(entry)
83
+ OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
84
+ end
85
+
86
+ def print_grouped_tags(dir, dim, groups, titles)
87
+ @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
88
+ groups.each do |key, rows|
89
+ label = dim == :area && key != "(root)" ? "#{key}/" : key
90
+ @out.puts
91
+ @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
92
+ width = rows.map { |row| row[:tag].length }.max || 0
93
+ cwidth = [ 3, *rows.map { |row| count_cell(row).length } ].max
94
+ rows.each do |row|
95
+ names = row[:concepts].map { |id| titles[id] || id }.join(", ")
96
+ @out.puts " #{row[:tag].ljust(width)} #{count_cell(row).rjust(cwidth)} #{truncate(names, 76)}"
97
+ end
98
+ end
99
+ end
100
+
101
+ # "2/3" when the tag spreads beyond this group, the plain count when it is
102
+ # local — so equality (locality 1.0) reads by absence.
103
+ def count_cell(row)
104
+ row[:count] == row[:total] ? row[:count].to_s : "#{row[:count]}/#{row[:total]}"
105
+ end
106
+
107
+ def print_grouped_tags_json(dir, dim, groups)
108
+ groups_json = groups.map do |key, rows|
109
+ rows_json = rows.map { |row| { "tag" => row[:tag], "count" => row[:count], "total" => row[:total], "concepts" => row[:concepts] } }
110
+ { dim.to_s => key, "count" => rows.size, "tags" => rows_json }
111
+ end
112
+ emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
113
+ end
114
+
115
+ def distinct_tags(groups)
116
+ groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
117
+ end
118
+ end
119
+
120
+ register(Tags)
121
+ end
122
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The type index: which types exist, how often, and on what.
6
+ class Types < Command
7
+ def self.id
8
+ :types
9
+ end
10
+
11
+ def self.group
12
+ :read
13
+ end
14
+
15
+ def self.help_rows
16
+ [
17
+ [ "types <dir|@slug> [--json] [filters]", "list types with their concepts, by count" ]
18
+ ]
19
+ end
20
+
21
+ def call(argv)
22
+ options = { json: false }
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
25
+ json_flags(o, options, "emit the type index as JSON")
26
+ filter_flags(o, options, :area, :tag)
27
+ help_flag(o)
28
+ end
29
+ dir = positional_dir(parser, argv) or return 2
30
+
31
+ print_inverted_index(dir, "Types", :type, "types", options)
32
+ end
33
+ end
34
+
35
+ register(Types)
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The §9 conformance judge: is this legal OKF? Binary and tolerant — it is
6
+ # forbidden from failing a bundle over a broken link or a missing optional
7
+ # field, which is lint's job. Exit 1 when non-conformant.
8
+ class Validate < Command
9
+ def self.id
10
+ :validate
11
+ end
12
+
13
+ def self.group
14
+ :judge
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "validate <dir|@slug> [--json]", "check OKF v0.1 conformance" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ options = { json: false }
25
+ parser = OptionParser.new do |o|
26
+ o.banner = "Usage: okf validate <dir|@slug> [--json]"
27
+ json_flags(o, options, "emit a JSON report")
28
+ help_flag(o)
29
+ end
30
+ dir = positional_dir(parser, argv) or return 2
31
+
32
+ result = OKF::Bundle::Folder.load(dir).validate
33
+ options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
34
+ result.valid? ? 0 : 1
35
+ end
36
+
37
+ private
38
+
39
+ def print_validation(dir, result)
40
+ counts = result.counts
41
+ @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
42
+ @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
43
+ result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
44
+ result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
45
+ if result.valid? && result.warnings.empty?
46
+ @out.puts " #{paint("✓ conformant — no issues", 32)}"
47
+ elsif result.valid?
48
+ @out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
49
+ else
50
+ @out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
51
+ end
52
+ end
53
+
54
+ def print_validation_json(dir, result)
55
+ emit_json(bundle_head(dir).merge(
56
+ "conformant" => result.valid?,
57
+ "counts" => result.counts,
58
+ "errors" => result.errors,
59
+ "warnings" => result.warnings
60
+ ))
61
+ end
62
+ end
63
+
64
+ register(Validate)
65
+ end
66
+ end