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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.okf/decisions/index.md +12 -0
  3. data/.okf/decisions/invents-no-analysis.md +53 -0
  4. data/.okf/decisions/no-version-ceilings.md +69 -0
  5. data/.okf/decisions/okf-capability-drift.md +120 -0
  6. data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
  7. data/.okf/decisions/registry-write-boundary.md +175 -0
  8. data/.okf/decisions/ruby-floor.md +59 -0
  9. data/.okf/decisions/search-facade-coupling.md +146 -0
  10. data/.okf/decisions/undeclared-width-dependency.md +73 -0
  11. data/.okf/index.md +28 -0
  12. data/.okf/interaction/cross-bundle-scope.md +61 -0
  13. data/.okf/interaction/deferred-search.md +49 -0
  14. data/.okf/interaction/esc-peels-one-layer.md +70 -0
  15. data/.okf/interaction/filter-escalates-to-search.md +57 -0
  16. data/.okf/interaction/following-links.md +82 -0
  17. data/.okf/interaction/index.md +12 -0
  18. data/.okf/interaction/key-routing.md +84 -0
  19. data/.okf/interaction/which-registry.md +85 -0
  20. data/.okf/log.md +38 -0
  21. data/.okf/rendering/ansi-aware-width.md +74 -0
  22. data/.okf/rendering/index.md +8 -0
  23. data/.okf/rendering/markdown-rendering-trap.md +63 -0
  24. data/.okf/rendering/status-vocabulary.md +45 -0
  25. data/.okf/rendering/whole-frame-painting.md +52 -0
  26. data/.okf/testing/ci-matrix.md +80 -0
  27. data/.okf/testing/headless-frames.md +74 -0
  28. data/.okf/testing/index.md +8 -0
  29. data/.okf/testing/pty-test.md +73 -0
  30. data/CHANGELOG.md +239 -0
  31. data/LICENSE.txt +201 -0
  32. data/NOTICE +10 -0
  33. data/README.md +194 -0
  34. data/lib/okf/plugin.rb +63 -0
  35. data/lib/okf/tui/app.rb +1908 -0
  36. data/lib/okf/tui/cli.rb +154 -0
  37. data/lib/okf/tui/model.rb +410 -0
  38. data/lib/okf/tui/refs.rb +63 -0
  39. data/lib/okf/tui/ui.rb +308 -0
  40. data/lib/okf/tui/version.rb +7 -0
  41. data/lib/okf/tui/views.rb +1648 -0
  42. data/lib/okf/tui/workspace.rb +527 -0
  43. data/lib/okf/tui.rb +76 -0
  44. metadata +229 -0
@@ -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
@@ -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