okf-tui 1.0.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.
- checksums.yaml +7 -0
- data/.okf/decisions/index.md +12 -0
- data/.okf/decisions/invents-no-analysis.md +53 -0
- data/.okf/decisions/no-version-ceilings.md +69 -0
- data/.okf/decisions/okf-capability-drift.md +120 -0
- data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
- data/.okf/decisions/registry-write-boundary.md +175 -0
- data/.okf/decisions/ruby-floor.md +59 -0
- data/.okf/decisions/search-facade-coupling.md +146 -0
- data/.okf/decisions/undeclared-width-dependency.md +73 -0
- data/.okf/index.md +28 -0
- data/.okf/interaction/cross-bundle-scope.md +61 -0
- data/.okf/interaction/deferred-search.md +49 -0
- data/.okf/interaction/esc-peels-one-layer.md +70 -0
- data/.okf/interaction/filter-escalates-to-search.md +57 -0
- data/.okf/interaction/following-links.md +82 -0
- data/.okf/interaction/index.md +12 -0
- data/.okf/interaction/key-routing.md +84 -0
- data/.okf/interaction/which-registry.md +85 -0
- data/.okf/log.md +38 -0
- data/.okf/rendering/ansi-aware-width.md +74 -0
- data/.okf/rendering/index.md +8 -0
- data/.okf/rendering/markdown-rendering-trap.md +63 -0
- data/.okf/rendering/status-vocabulary.md +45 -0
- data/.okf/rendering/whole-frame-painting.md +52 -0
- data/.okf/testing/ci-matrix.md +80 -0
- data/.okf/testing/headless-frames.md +74 -0
- data/.okf/testing/index.md +8 -0
- data/.okf/testing/pty-test.md +73 -0
- data/CHANGELOG.md +239 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +194 -0
- data/lib/okf/plugin.rb +63 -0
- data/lib/okf/tui/app.rb +1908 -0
- data/lib/okf/tui/cli.rb +154 -0
- data/lib/okf/tui/model.rb +410 -0
- data/lib/okf/tui/refs.rb +63 -0
- data/lib/okf/tui/ui.rb +308 -0
- data/lib/okf/tui/version.rb +7 -0
- data/lib/okf/tui/views.rb +1648 -0
- data/lib/okf/tui/workspace.rb +527 -0
- data/lib/okf/tui.rb +76 -0
- metadata +229 -0
data/lib/okf/tui/cli.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "okf/tui"
|
|
4
|
+
require_relative "refs"
|
|
5
|
+
|
|
6
|
+
module OKF
|
|
7
|
+
module TUI
|
|
8
|
+
# `okf tui`'s front end, and the only layer that parses argv, prints, and
|
|
9
|
+
# chooses the exit code. Everything beneath it returns data.
|
|
10
|
+
#
|
|
11
|
+
# There is one door: this gem ships no executable, and the plugin seam
|
|
12
|
+
# (lib/okf/plugin.rb) hands argv and the streams straight here. So this is
|
|
13
|
+
# the whole argument grammar, with nothing to keep a second one in step
|
|
14
|
+
# with.
|
|
15
|
+
#
|
|
16
|
+
# The argument shape mirrors `okf server`: naming directories is an ad-hoc
|
|
17
|
+
# look at them and never enrols them in the registry. Registering stays an
|
|
18
|
+
# explicit act — here, the `a` key in the bundles view.
|
|
19
|
+
#
|
|
20
|
+
# Output streams are injected (out:/err:) so the whole surface is driven in
|
|
21
|
+
# tests without a real terminal, the same contract OKF::CLI keeps.
|
|
22
|
+
class CLI
|
|
23
|
+
USAGE = <<~TEXT
|
|
24
|
+
usage: okf tui [options] [DIR|@slug...]
|
|
25
|
+
|
|
26
|
+
(no arguments) every bundle in the registry
|
|
27
|
+
DIR... those bundles, ad-hoc — the registry is left alone
|
|
28
|
+
@slug a registered bundle; bare @ is the registry default
|
|
29
|
+
@group every bundle a registry group resolves to
|
|
30
|
+
|
|
31
|
+
options:
|
|
32
|
+
-v, --version print the version
|
|
33
|
+
-h, --help print this message
|
|
34
|
+
|
|
35
|
+
The registry is the project-local .okf-registry.json when one is on the
|
|
36
|
+
path up from here, and $OKF_HOME (default ~/.okf) otherwise — whichever
|
|
37
|
+
one every other `okf` verb run from here resolves to. OKF_NO_DISCOVERY=1
|
|
38
|
+
forces the global one.
|
|
39
|
+
TEXT
|
|
40
|
+
|
|
41
|
+
# Exit codes, the same contract the `okf` CLI keeps.
|
|
42
|
+
OK = 0
|
|
43
|
+
FAILURE = 1
|
|
44
|
+
USAGE_ERROR = 2
|
|
45
|
+
|
|
46
|
+
def self.run(argv, out: $stdout, err: $stderr, input: $stdin)
|
|
47
|
+
new(argv, out: out, err: err, input: input).run
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(argv, out: $stdout, err: $stderr, input: $stdin)
|
|
51
|
+
@argv = argv.dup
|
|
52
|
+
@out = out
|
|
53
|
+
@err = err
|
|
54
|
+
@input = input
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
refs = []
|
|
59
|
+
|
|
60
|
+
until @argv.empty?
|
|
61
|
+
argument = @argv.shift
|
|
62
|
+
|
|
63
|
+
case argument
|
|
64
|
+
when "-h", "--help" then return print_and_exit(USAGE, OK)
|
|
65
|
+
when "-v", "--version" then return print_and_exit("okf-tui #{OKF::TUI::VERSION}\n", OK)
|
|
66
|
+
else
|
|
67
|
+
return usage_error("unknown option: #{argument}") if argument.start_with?("-")
|
|
68
|
+
|
|
69
|
+
# Not checked here on purpose: a positional may be a directory or an
|
|
70
|
+
# @ref, and only the registry can tell whether `@mkt` names anything.
|
|
71
|
+
# Refs resolves them together, so one place decides and one place
|
|
72
|
+
# reports.
|
|
73
|
+
refs << argument
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
start(refs)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# No `home` to pass: $OKF_HOME is the only lever, so the registry is
|
|
83
|
+
# located the same way here as it is for every `okf` verb. Workspace still
|
|
84
|
+
# takes `home:` — that is for an embedding app and the tests, which should
|
|
85
|
+
# not have to mutate a process-global to say which registry they mean.
|
|
86
|
+
#
|
|
87
|
+
# `cwd: Dir.pwd` is the other half of that: it is what opts this run into
|
|
88
|
+
# registry discovery, mirroring okf's own rule that only its CLI passes a
|
|
89
|
+
# cwd while a library caller stays global-only. Without it the TUI would be
|
|
90
|
+
# the one okf verb that ignores a project-local `.okf-registry.json` sitting
|
|
91
|
+
# right beside the bundles it is being asked about.
|
|
92
|
+
def start(refs)
|
|
93
|
+
return incompatible_okf("cannot answer a search — OKF::Bundle::Search is missing across/prepare/with") unless
|
|
94
|
+
OKF::TUI.search_capable?
|
|
95
|
+
return incompatible_okf("does not speak OKF v0.2 — Bundle#okf_version or RowFilter.shows_trust? is missing") unless
|
|
96
|
+
OKF::TUI.spec_capable?
|
|
97
|
+
|
|
98
|
+
# okf has already said what was wrong with the ref on @err.
|
|
99
|
+
resolver = Refs.new(out: @out, err: @err)
|
|
100
|
+
dirs = resolver.resolve(refs)
|
|
101
|
+
return USAGE_ERROR if dirs.nil?
|
|
102
|
+
|
|
103
|
+
app = App.new(dirs: dirs, ref_slugs: resolver.slugs, cwd: Dir.pwd, output: @out)
|
|
104
|
+
|
|
105
|
+
return empty_workspace(app) if app.workspace.empty?
|
|
106
|
+
# A terminal is the whole point; without one there is nothing to drive.
|
|
107
|
+
return usage_error("needs an interactive terminal") unless @input.respond_to?(:tty?) && @input.tty?
|
|
108
|
+
|
|
109
|
+
app.run
|
|
110
|
+
OK
|
|
111
|
+
rescue OKF::Error => e
|
|
112
|
+
@err.puts "okf-tui: #{e.message}"
|
|
113
|
+
FAILURE
|
|
114
|
+
rescue Interrupt
|
|
115
|
+
OK
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Loud on purpose: the alternative is a search view that finds nothing and
|
|
119
|
+
# gives no reason. It names the okf that answered, because the usual cause
|
|
120
|
+
# is a second one installed ahead of the intended checkout on the load path.
|
|
121
|
+
def incompatible_okf(reason)
|
|
122
|
+
@err.puts "okf-tui: this okf #{reason}"
|
|
123
|
+
@err.puts " loaded okf #{OKF::VERSION} from #{okf_location}"
|
|
124
|
+
FAILURE
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The file that actually answered, not a guess from the version number:
|
|
128
|
+
# OKF.blank? is defined in okf.rb itself, so its source_location is the
|
|
129
|
+
# library that got loaded.
|
|
130
|
+
def okf_location
|
|
131
|
+
OKF.method(:blank?).source_location.first
|
|
132
|
+
rescue StandardError
|
|
133
|
+
"an unknown location"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def empty_workspace(app)
|
|
137
|
+
@err.puts "okf-tui: nothing to show — the registry at #{app.workspace.registry_path} is empty"
|
|
138
|
+
@err.puts " register a bundle with `okf registry set <dir>`, or pass a directory or an @slug"
|
|
139
|
+
USAGE_ERROR
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def print_and_exit(text, status)
|
|
143
|
+
@out.print(text)
|
|
144
|
+
status
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def usage_error(message)
|
|
148
|
+
@err.puts "okf-tui: #{message}"
|
|
149
|
+
@err.puts USAGE
|
|
150
|
+
USAGE_ERROR
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "okf"
|
|
4
|
+
require "okf/registry"
|
|
5
|
+
|
|
6
|
+
module OKF::TUI
|
|
7
|
+
# One bundle, and everything the views ask about it.
|
|
8
|
+
#
|
|
9
|
+
# The split the gem enforces between its pure core and its shell is what makes
|
|
10
|
+
# this cheap: Bundle::Reader is the only thing here that touches disk, and
|
|
11
|
+
# every answer below is a pure call on the in-memory Bundle. The TUI is just
|
|
12
|
+
# another shell over the same core the CLI and the server use — it invents no
|
|
13
|
+
# analysis of its own.
|
|
14
|
+
#
|
|
15
|
+
# Analysis is memoized rather than computed at load: with a whole registry open
|
|
16
|
+
# at once, switching bundles should not pay for a validate and a lint of every
|
|
17
|
+
# bundle nobody has looked at yet.
|
|
18
|
+
class Model
|
|
19
|
+
attr_reader :dir, :bundle, :slug
|
|
20
|
+
|
|
21
|
+
def initialize(dir, slug: nil)
|
|
22
|
+
@dir = File.expand_path(dir)
|
|
23
|
+
@slug = slug
|
|
24
|
+
@bundle = OKF::Bundle::Reader.read(@dir)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def name
|
|
28
|
+
slug || File.basename(dir)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def catalog
|
|
32
|
+
@catalog ||= bundle.catalog
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def graph
|
|
36
|
+
@graph ||= bundle.graph(minimal: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validation
|
|
40
|
+
@validation ||= bundle.validate
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def lint
|
|
44
|
+
@lint ||= bundle.lint
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The spec version the bundle declares (§12), or nil where it declares none
|
|
48
|
+
# — which §12 permits, so nil is an answer and the view says "conformant"
|
|
49
|
+
# rather than guessing a number. Asked of okf rather than assumed: this
|
|
50
|
+
# screen told every reader "legal OKF v0.1" for a release, including about a
|
|
51
|
+
# bundle that had migrated.
|
|
52
|
+
def okf_version
|
|
53
|
+
bundle.okf_version
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The checks lint did not run, because it was handed no clock. §5.5's
|
|
57
|
+
# freshness pair is clock-gated, and the pure library confesses the omission
|
|
58
|
+
# here rather than reporting a verdict it did not earn — so the view says so
|
|
59
|
+
# too. Reporting "lint clean" over a check that never ran is the one way a
|
|
60
|
+
# health screen can be worse than no health screen.
|
|
61
|
+
def skipped_checks
|
|
62
|
+
Array(lint.stats[:skipped_checks])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The bundle's trust and status posture — the two distributions okf's own
|
|
66
|
+
# lint prints on its summary line. Hashes, so `stats_block` (which keeps
|
|
67
|
+
# scalars) drops them; they are named here because the standing pane is
|
|
68
|
+
# exactly where they belong, being short by construction and carrying no path.
|
|
69
|
+
def trust_posture
|
|
70
|
+
lint.stats[:trust] || {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def status_posture
|
|
74
|
+
lint.stats[:status] || {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# §5.3's display half, asked of okf. Whether a *tier* is one this screen
|
|
78
|
+
# should claim is not the same question as what the tier is: a concept that
|
|
79
|
+
# declared no §5 family derives `unverified` and has claimed nothing, which
|
|
80
|
+
# is every concept of every v0.1 bundle. okf owns the rule — its server, its
|
|
81
|
+
# graph page and this all read one predicate — and the reason it is shared is
|
|
82
|
+
# that a gate disagreeing with the counts beside it reads "unverified 3" over
|
|
83
|
+
# two chipped rows.
|
|
84
|
+
def self.shows_trust?(row)
|
|
85
|
+
OKF::Bundle::RowFilter.shows_trust?(row)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The catalog rows plus the one thing the catalog does not carry: the file
|
|
89
|
+
# path each concept came from. Everything else the browse list needs — the
|
|
90
|
+
# area, the in/out link degree — the catalog already computed.
|
|
91
|
+
def rows
|
|
92
|
+
@rows ||= begin
|
|
93
|
+
paths = bundle.paths_by_id
|
|
94
|
+
catalog.map { |entry| entry.merge(path: paths[entry[:id]].to_s) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every directory the bundle has — okf's own answer, not one derived here.
|
|
99
|
+
# Counting the catalog's directories instead gives a smaller set: the catalog
|
|
100
|
+
# knows only directories that hold concepts, so an intermediate that holds
|
|
101
|
+
# nothing but sub-directories, or one whose only file is a log, drops out.
|
|
102
|
+
# That is the disagreement okf 1.13.0 fixed by putting the question on the
|
|
103
|
+
# bundle, and asking it there is how the TUI cannot reintroduce it.
|
|
104
|
+
#
|
|
105
|
+
# Not memoized: Bundle#directories already is, and a second cache over an
|
|
106
|
+
# immutable model is a second thing to invalidate for no gain.
|
|
107
|
+
def dirs
|
|
108
|
+
bundle.directories
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def concept_count
|
|
112
|
+
bundle.concepts.length
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def edge_count
|
|
116
|
+
graph.edges.length
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def orphan_ids
|
|
120
|
+
@orphan_ids ||= graph.unlinked_ids
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Concepts ranked by inbound link degree, each carrying where those links come
|
|
124
|
+
# from — okf's `graph --hubs`, unchanged.
|
|
125
|
+
#
|
|
126
|
+
# The graph view already ranks by inbound degree, so what this adds is the
|
|
127
|
+
# `by_top_dir` breakdown, which is the whole point: it is the evidence for
|
|
128
|
+
# "is this hub well homed?". A hub whose inbound majority is foreign to its own
|
|
129
|
+
# top-level dir is a move candidate, and one with a single dominant foreign dir
|
|
130
|
+
# has already named its better home. That judgement is okf's, and this is the
|
|
131
|
+
# method that makes it.
|
|
132
|
+
def hubs
|
|
133
|
+
@hubs ||= bundle.hubs
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The link graph one grain coarser: each directory with its internal, outbound
|
|
137
|
+
# and inbound traffic, and the internal share of that total as a cohesion —
|
|
138
|
+
# okf's `graph --traffic`.
|
|
139
|
+
#
|
|
140
|
+
# `Bundle::Skeleton` is okf's pure model here; the arithmetic below is the
|
|
141
|
+
# aggregation its CLI view does, and only that. Cohesion is internal over
|
|
142
|
+
# total, and nil rather than 0% for a directory with no traffic at all, because
|
|
143
|
+
# a directory with nothing to weigh has not earned a number.
|
|
144
|
+
#
|
|
145
|
+
# Counted over *every* arc, never a narrowed set: okf makes a point of this —
|
|
146
|
+
# the cut it suggests narrows the drawn picture and must never move the
|
|
147
|
+
# evidence. Sorted by cohesion ascending, so the directories with a case to
|
|
148
|
+
# answer come first rather than sitting under the ones nobody needed to read.
|
|
149
|
+
def dir_traffic
|
|
150
|
+
@dir_traffic ||= begin
|
|
151
|
+
skeleton = bundle.skeleton
|
|
152
|
+
out = Hash.new(0)
|
|
153
|
+
into = Hash.new(0)
|
|
154
|
+
skeleton.arcs.each do |arc|
|
|
155
|
+
out[arc[:source]] += arc[:weight]
|
|
156
|
+
into[arc[:target]] += arc[:weight]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
rows = skeleton.dirs.map do |row|
|
|
160
|
+
total = row[:internal] + out[row[:dir]] + into[row[:dir]]
|
|
161
|
+
row.merge(out: out[row[:dir]], in: into[row[:dir]],
|
|
162
|
+
cohesion: total.zero? ? nil : (100.0 * row[:internal] / total).round)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
rows.sort_by { |row| [ row[:cohesion] || 999, row[:dir] ] }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The cross-directory link mass, as weighted arcs, narrowed to the cut okf
|
|
170
|
+
# suggests for this bundle.
|
|
171
|
+
#
|
|
172
|
+
# The cut is *fitted*, not fixed — okf measured ten bundles at weight 3 and got
|
|
173
|
+
# anywhere from 2 arcs to 136 — so `suggested_cut` is asked rather than guessed.
|
|
174
|
+
# It narrows only the drawn picture: cohesion above is computed over every arc
|
|
175
|
+
# regardless, which is okf's own rule, and the reason the two can be shown
|
|
176
|
+
# together without the number moving when the list gets shorter.
|
|
177
|
+
def dir_arcs
|
|
178
|
+
@dir_arcs ||= begin
|
|
179
|
+
skeleton = bundle.skeleton
|
|
180
|
+
cut = skeleton.suggested_cut
|
|
181
|
+
[ OKF::Bundle::Skeleton.arcs_above(skeleton.arcs, cut).sort_by { |arc| -arc[:weight] },
|
|
182
|
+
cut, skeleton.arcs.length ]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def concept_by_id(id)
|
|
187
|
+
bundle.concept_by_id(id)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def row_by_id(id)
|
|
191
|
+
rows.find { |row| row[:id] == id }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def types
|
|
195
|
+
types_of(rows)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def tags
|
|
199
|
+
tags_of(rows)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The same tallies over an arbitrary subset, so the graph view can count
|
|
203
|
+
# within a facet ("among Capability concepts, which tags?") rather than only
|
|
204
|
+
# over the whole bundle.
|
|
205
|
+
# okf's one rule for `--dir`: a directory names itself and everything beneath
|
|
206
|
+
# it. `.` needs no special case — nothing starts with "./", so the root
|
|
207
|
+
# selects only what lives directly in it, which is why okf's own `dirs` view
|
|
208
|
+
# reports a subtree of 1 for the root of a five-concept bundle.
|
|
209
|
+
#
|
|
210
|
+
# Asked rather than re-spelled. This was a hand-written copy — byte-identical
|
|
211
|
+
# to okf's, which is the good case and still the wrong one: okf published the
|
|
212
|
+
# rule as `Bundle::RowFilter.under_dir?` precisely because three shells had
|
|
213
|
+
# spelled it separately and diverged three recorded times. Both sides are
|
|
214
|
+
# already okf's canonical spellings — a row's `dir` is `OKF.dir_of`, a facet's
|
|
215
|
+
# value came out of `Bundle#directories` — so none of okf's argument handling
|
|
216
|
+
# applies: no `root` alias to resolve, no trailing slash to trim, nothing here
|
|
217
|
+
# was typed by a user.
|
|
218
|
+
#
|
|
219
|
+
# dirs_test.rb pins the result against okf's own subtree counts — agreement on
|
|
220
|
+
# answers, which is what actually matters and what would survive okf changing
|
|
221
|
+
# the rule underneath.
|
|
222
|
+
def self.under_dir?(dir, ancestor)
|
|
223
|
+
OKF::Bundle::RowFilter.under_dir?(dir, ancestor)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# okf's own label for a concept whose type is missing or blank — the one its
|
|
227
|
+
# graph index uses, so the two agree about what the bundle contains.
|
|
228
|
+
UNTYPED = "Untyped"
|
|
229
|
+
|
|
230
|
+
def self.type_label(type)
|
|
231
|
+
OKF.blank?(type) ? UNTYPED : type.to_s
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def types_of(subset)
|
|
235
|
+
tally(subset.map { |row| Model.type_label(row[:type]) })
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def tags_of(subset)
|
|
239
|
+
tally(subset.flat_map { |row| row[:tags] })
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# §5.4, counted on the *effective* value — the same rule `--status` narrows
|
|
243
|
+
# by, so a concept that declared nothing is counted as the `stable` it already
|
|
244
|
+
# means rather than dropped. Without that the group would hide the majority
|
|
245
|
+
# the one deprecated concept is measured against.
|
|
246
|
+
def statuses_of(subset)
|
|
247
|
+
tally(subset.map { |row| OKF::Concept.effective_status(row[:status]) })
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# §5.3, counted only over rows whose tier this screen is willing to claim.
|
|
251
|
+
# Counting the rest would make the facet promise more concepts than selecting
|
|
252
|
+
# it returns — okf hit exactly that and describes it as "unverified 3" over
|
|
253
|
+
# two chipped cards.
|
|
254
|
+
def tiers_of(subset)
|
|
255
|
+
tally(subset.select { |row| Model.shows_trust?(row) }.map { |row| row[:trust] })
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Each directory with the number of concepts at or below it — the same
|
|
259
|
+
# "subtree" okf's `dirs` view prints, and by construction exactly what
|
|
260
|
+
# narrowing to that directory will yield.
|
|
261
|
+
#
|
|
262
|
+
# Kept in okf's order (root first, then alphabetically) rather than sorted by
|
|
263
|
+
# count like types and tags: a directory list sorted by size scrambles parents
|
|
264
|
+
# away from their children, and the shape of the tree is the thing full-path
|
|
265
|
+
# dirs exist to show.
|
|
266
|
+
#
|
|
267
|
+
# A directory with nothing under it is dropped — `history/`, whose only file is
|
|
268
|
+
# its log, is a real directory that okf counts and `--dir` addresses, but as a
|
|
269
|
+
# facet it is a row that narrows to nothing. The header's dir count still
|
|
270
|
+
# includes it; the two answer different questions.
|
|
271
|
+
def dirs_of(subset)
|
|
272
|
+
dirs.map { |dir| [ dir, subset.count { |row| Model.under_dir?(row[:dir], dir) } ] }
|
|
273
|
+
.reject { |_dir, count| count.zero? }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Findings keyed by the concept path they were raised against, so the browse
|
|
277
|
+
# pane can badge a concept with its own problems.
|
|
278
|
+
def findings_by_path
|
|
279
|
+
@findings_by_path ||= lint.findings.group_by { |finding| finding[:path] }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def findings_for(row)
|
|
283
|
+
findings_by_path[row[:id]] || findings_by_path[row[:path]] || []
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# index.md and log.md — structure rather than concepts, so the reader keeps
|
|
287
|
+
# them as raw text (§6). The browse list shows them because they are part of
|
|
288
|
+
# what is in the bundle, and often the first thing worth reading.
|
|
289
|
+
def reserved
|
|
290
|
+
bundle.reserved
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# The body without its frontmatter, exactly as Bundle#directory_index does
|
|
294
|
+
# it — the reader keeps reserved files as raw text, and the header is
|
|
295
|
+
# metadata rather than something to read.
|
|
296
|
+
def reserved_text(path)
|
|
297
|
+
content = bundle.reserved_content(path)
|
|
298
|
+
OKF::Markdown::Frontmatter.parse(content).last
|
|
299
|
+
rescue OKF::Markdown::Frontmatter::ParseError
|
|
300
|
+
content
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def body_for(row)
|
|
304
|
+
concept = concept_by_id(row[:id])
|
|
305
|
+
concept ? concept.body.to_s : ""
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The outgoing cross-links of one document, in reading order and deduped by
|
|
309
|
+
# target — what the reader can follow out of the page it is on.
|
|
310
|
+
#
|
|
311
|
+
# okf owns both halves. Markdown::Links is the same extraction Bundle::Graph
|
|
312
|
+
# builds its edges with and the validator warns on, so a link the TUI offers
|
|
313
|
+
# to follow is one okf already resolved; nothing here opens a file or parses
|
|
314
|
+
# markdown of its own. Memoized per path, like every other analysis.
|
|
315
|
+
def links_for(path)
|
|
316
|
+
@links_for ||= {}
|
|
317
|
+
return @links_for[path] if @links_for.key?(path)
|
|
318
|
+
|
|
319
|
+
@links_for[path] = build_links(path)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
def id_by_path
|
|
325
|
+
@id_by_path ||= bundle.paths_by_id.map { |id, path| [ path, id ] }.to_h
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def reserved_paths
|
|
329
|
+
@reserved_paths ||= reserved.map { |entry| [ entry.path, true ] }.to_h
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# A concept body or a reserved file's text — both are documents to read, so
|
|
333
|
+
# both can be followed out of.
|
|
334
|
+
def document_body(path)
|
|
335
|
+
id = id_by_path[path]
|
|
336
|
+
return concept_by_id(id).body.to_s if id
|
|
337
|
+
|
|
338
|
+
reserved_paths.key?(path) ? reserved_text(path).to_s : ""
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def build_links(path)
|
|
342
|
+
seen = {}
|
|
343
|
+
OKF::Markdown::Links.extract(document_body(path)).each_with_object([]) do |raw, links|
|
|
344
|
+
target = resolve_target(raw, path)
|
|
345
|
+
next if target.nil? || target == path || seen.key?(target)
|
|
346
|
+
|
|
347
|
+
seen[target] = true
|
|
348
|
+
links << describe_link(target)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# okf returns nil for a directory target, and is right to: `decisions/` is
|
|
353
|
+
# not a graph edge and the validator has nothing to check about it. But it is
|
|
354
|
+
# how every index.md points at its area, and §6 makes index.md the way in —
|
|
355
|
+
# so the *root* index, the bundle's front door, would otherwise be the one
|
|
356
|
+
# page with nothing to follow. Reading it as that directory's index is a
|
|
357
|
+
# lookup in the reserved list okf already handed us: no directory is walked,
|
|
358
|
+
# no markdown is parsed, and a directory with no index.md stays unfollowable.
|
|
359
|
+
def resolve_target(raw, from)
|
|
360
|
+
resolved = OKF::Markdown::Links.resolve(raw, from: from, bundle: bundle.root)
|
|
361
|
+
return resolved if resolved
|
|
362
|
+
|
|
363
|
+
target = raw.to_s.split("#", 2).first.to_s
|
|
364
|
+
return nil unless target.end_with?("/")
|
|
365
|
+
|
|
366
|
+
index = OKF::Markdown::Links.resolve("#{target}index.md", from: from, bundle: bundle.root)
|
|
367
|
+
index && reserved_paths.key?(index) ? index : nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# What sits at the other end: a concept, a reserved file, or nothing yet.
|
|
371
|
+
# A target that resolves but is not in the bundle is not-yet-written
|
|
372
|
+
# knowledge rather than an error — okf's own position, and lint is where it
|
|
373
|
+
# gets reported.
|
|
374
|
+
def describe_link(target)
|
|
375
|
+
id = id_by_path[target]
|
|
376
|
+
if id
|
|
377
|
+
row = row_by_id(id)
|
|
378
|
+
title = row ? row[:title].to_s : ""
|
|
379
|
+
return { target: target, kind: :concept, id: id, type: row && row[:type],
|
|
380
|
+
label: title.empty? ? id : title }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
return { target: target, kind: :reserved, id: nil, type: nil, label: reserved_label(target) } if reserved_paths.key?(target)
|
|
384
|
+
|
|
385
|
+
{ target: target, kind: :missing, id: nil, type: nil, label: target }
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# A nested index.md is really the name of its area — that is what the link
|
|
389
|
+
# meant, and what the reader is going to.
|
|
390
|
+
def reserved_label(target)
|
|
391
|
+
dir = File.dirname(target)
|
|
392
|
+
base = File.basename(target)
|
|
393
|
+
return base if dir == "."
|
|
394
|
+
|
|
395
|
+
base == "index.md" ? dir : "#{dir}/#{base}"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Blank values are dropped — a tag cannot be blank and a blank *type* has already
|
|
399
|
+
# become UNTYPED by the time it arrives here. It used to be dropped instead, so
|
|
400
|
+
# the type facet silently omitted every untyped concept: no row, and no way to
|
|
401
|
+
# narrow to them, in the one view for exploring the bundle's shape — and untyped
|
|
402
|
+
# concepts are exactly what a curator is looking for, since §9.2 requires a type.
|
|
403
|
+
# okf names them rather than hiding them, and structure_test pins the agreement.
|
|
404
|
+
def tally(values)
|
|
405
|
+
counts = Hash.new(0)
|
|
406
|
+
values.each { |value| counts[value.to_s] += 1 unless OKF.blank?(value) }
|
|
407
|
+
counts.sort_by { |value, count| [ -count, value ] }
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
data/lib/okf/tui/refs.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "okf/cli"
|
|
4
|
+
|
|
5
|
+
module OKF
|
|
6
|
+
module TUI
|
|
7
|
+
# argv → bundle directories, through okf's ref grammar rather than a second
|
|
8
|
+
# copy of it.
|
|
9
|
+
#
|
|
10
|
+
# `okf-tui @okf @mkt ./docs` has to mean exactly what `okf server @okf @mkt
|
|
11
|
+
# ./docs` means: bare `@` is the registry default, `@slug` is a registered
|
|
12
|
+
# bundle, `@group` fans out to its members, a member whose directory has
|
|
13
|
+
# vanished is skipped with a note, `@all` is refused by name, and anything
|
|
14
|
+
# else is a directory on disk. That grammar is okf's — its precedence, its
|
|
15
|
+
# messages, its exit codes — and this class exists so there is one copy of it
|
|
16
|
+
# rather than a faithful-until-it-drifts imitation.
|
|
17
|
+
#
|
|
18
|
+
# It subclasses Command for the resolver, not to be a verb. Nothing registers
|
|
19
|
+
# it, so it can never answer to one; `.id` is defined only because Command
|
|
20
|
+
# declares it abstract. The helpers it leans on are private, which is
|
|
21
|
+
# Command's way of saying "not a verb" ("#call is the entire public surface,
|
|
22
|
+
# so a helper added below can never become a verb by accident") rather than
|
|
23
|
+
# "not for subclasses" — but they are still okf's internals, so
|
|
24
|
+
# test/integration/refs_test.rb pins the seam by name. If okf renames it, that
|
|
25
|
+
# test says so, instead of every `@slug` quietly going back to reading as
|
|
26
|
+
# "not a directory".
|
|
27
|
+
#
|
|
28
|
+
# Resolution is also what opts the TUI into registry discovery: Command's
|
|
29
|
+
# `open_registry` is `OKF::Registry.load(cwd: Dir.pwd)`, so a `@slug` here
|
|
30
|
+
# means the same bundle it means to every other okf verb run from the same
|
|
31
|
+
# directory — the project-local `.okf-registry.json` when there is one on the
|
|
32
|
+
# path up, the global `$OKF_HOME` registry otherwise.
|
|
33
|
+
class Refs < OKF::CLI::Command
|
|
34
|
+
def self.id
|
|
35
|
+
:"tui-refs"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The resolved directories in argv order, groups fanned out — or nil, after
|
|
39
|
+
# okf has already reported why on the error stream, so the caller returns
|
|
40
|
+
# its usage-error status exactly as `okf server` does.
|
|
41
|
+
#
|
|
42
|
+
# An empty argv resolves to an empty list rather than nil: naming no bundle
|
|
43
|
+
# is not a failed ref, it is the registry-backed session.
|
|
44
|
+
def resolve(argv)
|
|
45
|
+
dirs = Array(argv).flat_map { |arg| resolve_ref_expanding(arg) }
|
|
46
|
+
dirs.include?(nil) ? nil : dirs
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Which registered slug each resolved directory came from, keyed by
|
|
50
|
+
# directory — empty for plain directory arguments.
|
|
51
|
+
#
|
|
52
|
+
# Without this a ref-built session names bundles after their folders, and
|
|
53
|
+
# the folder is `.okf` for every bundle that follows the convention: `okf-tui
|
|
54
|
+
# @okf-site @okf-mkt` would list `@okf` and `@okf-2`, having thrown away the
|
|
55
|
+
# two names the user actually typed. okf hit this in the hub and fixed it the
|
|
56
|
+
# same way — "so a hub built from refs mounts each bundle under its
|
|
57
|
+
# registered slug, not its dir basename".
|
|
58
|
+
def slugs
|
|
59
|
+
ref_slugs
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|