okf 1.12.0 → 2.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 +4 -4
- data/CHANGELOG.md +296 -0
- data/README.md +94 -466
- data/lib/okf/bundle/folder.rb +48 -3
- data/lib/okf/bundle/graph.rb +12 -3
- data/lib/okf/bundle/linter.rb +470 -47
- data/lib/okf/bundle/reader.rb +47 -18
- data/lib/okf/bundle/references.rb +111 -0
- data/lib/okf/bundle/row_filter.rb +53 -0
- data/lib/okf/bundle/search.rb +20 -2
- data/lib/okf/bundle/validator/result.rb +6 -3
- data/lib/okf/bundle/validator.rb +267 -26
- data/lib/okf/bundle/writer.rb +1 -1
- data/lib/okf/bundle.rb +124 -8
- data/lib/okf/cli/catalog.rb +2 -2
- data/lib/okf/cli/command.rb +93 -19
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/index.rb +3 -3
- data/lib/okf/cli/lint.rb +70 -12
- data/lib/okf/cli/references.rb +97 -0
- data/lib/okf/cli/search.rb +23 -8
- data/lib/okf/cli/stats.rb +3 -39
- data/lib/okf/cli/tags.rb +6 -43
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli/validate.rb +3 -3
- data/lib/okf/cli.rb +4 -1
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/concept.rb +362 -10
- data/lib/okf/markdown/citations.rb +41 -4
- data/lib/okf/markdown/frontmatter.rb +1 -1
- data/lib/okf/markdown/links.rb +67 -7
- data/lib/okf/path.rb +17 -3
- data/lib/okf/render/graph/template.html.erb +173 -41
- data/lib/okf/render/graph.rb +11 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/server/app.rb +47 -15
- data/lib/okf/server/hub.rb +1 -1
- data/lib/okf/skill/SKILL.md +14 -12
- data/lib/okf/skill/playbooks/curate.md +8 -3
- data/lib/okf/skill/playbooks/doctor.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -6
- data/lib/okf/skill/playbooks/menu.md +5 -4
- data/lib/okf/skill/playbooks/migrate.md +31 -8
- data/lib/okf/skill/playbooks/produce.md +16 -9
- data/lib/okf/skill/playbooks/search.md +2 -2
- data/lib/okf/skill/reference/SPEC.md +739 -187
- data/lib/okf/skill/reference/authoring.md +154 -35
- data/lib/okf/skill/reference/cli.md +160 -44
- data/lib/okf/skill/templates/attested-computation.md +41 -0
- data/lib/okf/skill/templates/concept.md +13 -6
- data/lib/okf/skill/templates/root-index.md +1 -1
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +23 -2
- metadata +7 -3
- data/CODE_OF_CONDUCT.md +0 -10
data/lib/okf/cli/command.rb
CHANGED
|
@@ -23,6 +23,11 @@ module OKF
|
|
|
23
23
|
# it is installed instead of the first time a user types its verb.
|
|
24
24
|
DUCK_TYPE = %i[id group help_rows hidden? new].freeze
|
|
25
25
|
|
|
26
|
+
# Every key a filter flag can set. One list, read by both the narrowing
|
|
27
|
+
# itself and the "is anything narrowing?" guard, so adding a filter cannot
|
|
28
|
+
# leave one of them behind.
|
|
29
|
+
FILTER_KEYS = %i[type area dir tag status trust].freeze
|
|
30
|
+
|
|
26
31
|
class << self
|
|
27
32
|
# The verb this answers to, as a Symbol. The registry is keyed on it.
|
|
28
33
|
def id
|
|
@@ -188,41 +193,103 @@ module OKF
|
|
|
188
193
|
end
|
|
189
194
|
end
|
|
190
195
|
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
196
|
+
# The read-side payoff of v0.2, and the two questions no v0.1 tool could
|
|
197
|
+
# answer: which concepts nobody has verified, and which are on their way
|
|
198
|
+
# out. Unconditional — every filtering verb narrows the same catalog
|
|
199
|
+
# rows, and on a v0.1 bundle the answers still read (`stable` for every
|
|
200
|
+
# concept, `unverified` for every concept — §13.1, not an error).
|
|
201
|
+
parser.on("--status STATUS", "only concepts at this lifecycle status (draft | stable | deprecated)") do |v|
|
|
202
|
+
options[:status] = v
|
|
203
|
+
end
|
|
204
|
+
parser.on("--trust TIER", "only concepts at this trust tier (unverified | machine-confirmed | human-reviewed)") do |v|
|
|
205
|
+
options[:trust] = v
|
|
206
|
+
end
|
|
191
207
|
end
|
|
192
208
|
|
|
193
|
-
|
|
209
|
+
# The argument is resolved once per view, not once per entry: the `root`
|
|
210
|
+
# alias below has to consult the bundle's own directories, and that is a
|
|
211
|
+
# question about the whole set rather than about the row in hand.
|
|
212
|
+
#
|
|
213
|
+
# +dirs+ is that set, and every in-tree caller passes it (see #dir_scope)
|
|
214
|
+
# rather than deriving it from +entries+ — the catalog knows only
|
|
215
|
+
# directories that hold *concepts*, so a `root/` carrying an index.md and
|
|
216
|
+
# nothing else was invisible here while `dirs` and `index` (which read
|
|
217
|
+
# Bundle#directories) saw it. Two answers to one question about one
|
|
218
|
+
# bundle is how the alias survived its own fix.
|
|
219
|
+
#
|
|
220
|
+
# The default is a compatibility promise, not a shortcut: this base is
|
|
221
|
+
# what every plugin verb inherits, and 1.12.0 shipped the two-argument
|
|
222
|
+
# shape. An out-of-tree caller that never learned the third argument gets
|
|
223
|
+
# exactly the resolution it was written against — the alias folds with no
|
|
224
|
+
# directory set consulted — no better and no worse.
|
|
225
|
+
# The row rules live in Bundle::RowFilter, shared with the MCP shell;
|
|
226
|
+
# this layer keeps only what is the CLI's own — the deprecated --area
|
|
227
|
+
# (a top_dir compare no other surface offers) and the argument spelling
|
|
228
|
+
# (the `root`/`.` aliases resolved against the bundle's real dirs).
|
|
229
|
+
def filter_entries(entries, options, dirs = nil)
|
|
230
|
+
area = options[:area] && fold_area(options[:area], dirs)
|
|
231
|
+
base = options[:dir] && fold_dir(options[:dir], dirs)
|
|
194
232
|
entries.select do |entry|
|
|
195
|
-
(
|
|
196
|
-
(options[:
|
|
197
|
-
|
|
198
|
-
(options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
|
|
233
|
+
(area.nil? || fold(entry[:top_dir]) == area) &&
|
|
234
|
+
Bundle::RowFilter.matches?(entry, type: options[:type], dir: base, tag: options[:tag],
|
|
235
|
+
status: options[:status], trust: options[:trust])
|
|
199
236
|
end
|
|
200
237
|
end
|
|
201
238
|
|
|
239
|
+
# The directory set only when a flag is going to consult it. Deriving it
|
|
240
|
+
# walks every path up to the root, and #filter_entries reads it solely to
|
|
241
|
+
# resolve the `root`/`--area` aliases — a plain listing, or a type/tag
|
|
242
|
+
# narrowing, never needs the walk.
|
|
243
|
+
def dir_scope(folder, options)
|
|
244
|
+
options[:dir] || options[:area] ? folder.directories : nil
|
|
245
|
+
end
|
|
246
|
+
|
|
202
247
|
# The one rule --dir is built on: a dir names itself and everything beneath
|
|
203
248
|
# it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
|
|
204
249
|
# needs no special case at all — nothing starts with "./", so the root
|
|
205
250
|
# selects only what lives directly in it.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
251
|
+
#
|
|
252
|
+
# +path+ arrives already folded, through #fold_dir for a user's argument
|
|
253
|
+
# and #fold for a stored one. A stored dir is never an alias — that is the
|
|
254
|
+
# distinction the old signature could not make, and it is what had a row
|
|
255
|
+
# named `root` counting the bundle root's subtree instead of its own.
|
|
256
|
+
# The comparison itself is Bundle::RowFilter's (fold is idempotent).
|
|
257
|
+
def under_dir?(entry_dir, path)
|
|
258
|
+
Bundle::RowFilter.under_dir?(entry_dir, path)
|
|
210
259
|
end
|
|
211
260
|
|
|
212
261
|
def fold(value)
|
|
213
262
|
value.to_s.downcase
|
|
214
263
|
end
|
|
215
264
|
|
|
216
|
-
def fold_area(value)
|
|
265
|
+
def fold_area(value, known = nil)
|
|
217
266
|
folded = trim_slash(fold(value))
|
|
218
|
-
folded == "root" ? "(root)" : folded
|
|
267
|
+
folded == "root" && !names_dir?(known, "root") ? "(root)" : folded
|
|
219
268
|
end
|
|
220
269
|
|
|
221
270
|
# `.` is the stored spelling of the root everywhere; `root` is the one a
|
|
222
271
|
# shell needs no quoting for, and the only reason the two exist.
|
|
223
|
-
|
|
272
|
+
#
|
|
273
|
+
# A bundle that really has a `root/` directory owns the word, and +known+
|
|
274
|
+
# — the directories that bundle actually holds — is what decides. The
|
|
275
|
+
# alias is a convenience; being able to name a directory at all is not, so
|
|
276
|
+
# the convenience yields. Without this, `--dir root` answered for the
|
|
277
|
+
# bundle root in such a bundle: the wrong concepts, exit 0, nothing said.
|
|
278
|
+
def fold_dir(value, known = nil)
|
|
224
279
|
folded = trim_slash(fold(value))
|
|
225
|
-
|
|
280
|
+
return "." if folded.empty?
|
|
281
|
+
|
|
282
|
+
folded == "root" && !names_dir?(known, "root") ? "." : folded
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Does the bundle hold a directory by this name? An ancestor counts: a
|
|
286
|
+
# bundle whose only concept sits in `root/deep` still has a `root`, and
|
|
287
|
+
# `--dir root` has to reach it by the prefix rule above.
|
|
288
|
+
def names_dir?(known, name)
|
|
289
|
+
Array(known).any? do |dir|
|
|
290
|
+
folded = fold(dir)
|
|
291
|
+
folded == name || folded.start_with?("#{name}/")
|
|
292
|
+
end
|
|
226
293
|
end
|
|
227
294
|
|
|
228
295
|
# The human views print a directory with the slash that says it is one —
|
|
@@ -307,7 +374,7 @@ module OKF
|
|
|
307
374
|
|
|
308
375
|
stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
|
|
309
376
|
Array(options[:dirs]).each_with_object([]) do |path, out|
|
|
310
|
-
base = fold_dir(path)
|
|
377
|
+
base = fold_dir(path, known)
|
|
311
378
|
next unless stored.key?(base)
|
|
312
379
|
|
|
313
380
|
current = dir_parent(base)
|
|
@@ -331,7 +398,7 @@ module OKF
|
|
|
331
398
|
# they do not define one. The ancestor chain is unioned on top by the
|
|
332
399
|
# caller, which is also what tells a row apart from context.
|
|
333
400
|
def select_dirs(dirs, options)
|
|
334
|
-
bases = Array(options[:dirs]).map { |path| fold_dir(path) }
|
|
401
|
+
bases = Array(options[:dirs]).map { |path| fold_dir(path, dirs) }
|
|
335
402
|
depth = options[:depth]&.to_i
|
|
336
403
|
return dirs if bases.empty? && depth.nil?
|
|
337
404
|
# No --dir means the whole bundle is the starting point, which is *not*
|
|
@@ -373,13 +440,20 @@ module OKF
|
|
|
373
440
|
|
|
374
441
|
# The ids the filters select, resolved through the catalog metadata — or nil
|
|
375
442
|
# when no filter is active, meaning keep everything.
|
|
376
|
-
|
|
377
|
-
|
|
443
|
+
#
|
|
444
|
+
# +dirs+ overrides the folder's own directory set, which is what a
|
|
445
|
+
# multi-bundle run passes: the `root` alias is a fact about a bundle, so
|
|
446
|
+
# resolving it per folder inside a loop let one `--dir root` mean the
|
|
447
|
+
# `root/` subtree in one bundle and the bundle root in the next, merged
|
|
448
|
+
# into a single ranking with nothing saying so. One invocation, one
|
|
449
|
+
# meaning — see Search#multi_search.
|
|
450
|
+
def filter_ids(folder, options, dirs = nil)
|
|
451
|
+
return nil if FILTER_KEYS.none? { |key| options[key] }
|
|
378
452
|
|
|
379
|
-
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
453
|
+
filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
|
|
380
454
|
end
|
|
381
455
|
|
|
382
|
-
# §
|
|
456
|
+
# §11 best-effort: the graph is built from concepts that parse. Surface any that
|
|
383
457
|
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
384
458
|
# than dropping them silently.
|
|
385
459
|
def report_skipped(folder)
|
data/lib/okf/cli/dirs.rb
CHANGED
|
@@ -79,7 +79,7 @@ module OKF
|
|
|
79
79
|
def subtree_counts(entries)
|
|
80
80
|
entries.each_with_object({}) do |entry, out|
|
|
81
81
|
out[entry[:dir]] = entries.reduce(0) do |sum, other|
|
|
82
|
-
under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
|
|
82
|
+
under_dir?(other[:dir], fold(entry[:dir])) ? sum + other[:count] : sum
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
end
|
data/lib/okf/cli/files.rb
CHANGED
|
@@ -22,7 +22,7 @@ module OKF
|
|
|
22
22
|
def call(argv)
|
|
23
23
|
options = { json: false }
|
|
24
24
|
parser = OptionParser.new do |o|
|
|
25
|
-
o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
|
|
25
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
|
|
26
26
|
json_flags(o, options, "emit the file tree as JSON")
|
|
27
27
|
projection_flags(o, options)
|
|
28
28
|
filter_flags(o, options, :type, :area, :tag)
|
|
@@ -33,7 +33,7 @@ module OKF
|
|
|
33
33
|
folder = OKF::Bundle::Folder.load(dir)
|
|
34
34
|
report_skipped(folder)
|
|
35
35
|
entries = folder.catalog
|
|
36
|
-
selected = filter_entries(entries, options)
|
|
36
|
+
selected = filter_entries(entries, options, dir_scope(folder, options))
|
|
37
37
|
return print_files_json(dir, selected, options) if options[:json]
|
|
38
38
|
|
|
39
39
|
print_files(dir, selected, entries.size)
|
data/lib/okf/cli/index.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class CLI
|
|
5
|
-
# The progressive-disclosure map (spec §
|
|
5
|
+
# The progressive-disclosure map (spec §8): every directory that holds concepts
|
|
6
6
|
# or carries an index.md, with its authored index body, a type/tag rollup, its
|
|
7
7
|
# child directories, and — for a directory with no index.md — the listing
|
|
8
8
|
# synthesized from the concepts there. The "orient before you read" view. `--dir`
|
|
@@ -91,11 +91,11 @@ module OKF
|
|
|
91
91
|
# deprecated flag that quietly widens is worse than one that is merely old.
|
|
92
92
|
# Nothing passed keeps the whole map.
|
|
93
93
|
def select_directories(entries, options)
|
|
94
|
-
|
|
94
|
+
all_dirs = entries.map { |entry| entry[:dir] }
|
|
95
|
+
areas = Array(options[:areas]).map { |area| fold_dir(area, all_dirs) }
|
|
95
96
|
scoped = !options[:dirs].nil? || !options[:depth].nil?
|
|
96
97
|
return [ entries, [] ] if areas.empty? && !scoped
|
|
97
98
|
|
|
98
|
-
all_dirs = entries.map { |entry| entry[:dir] }
|
|
99
99
|
wanted = scoped ? select_dirs(all_dirs, options) : []
|
|
100
100
|
chain = ancestor_dirs(options, all_dirs) - wanted
|
|
101
101
|
selected = entries.select do |entry|
|
data/lib/okf/cli/lint.rb
CHANGED
|
@@ -10,12 +10,19 @@ module OKF
|
|
|
10
10
|
LINT_CATEGORIES = {
|
|
11
11
|
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
12
12
|
"Backlog" => %i[missing_concept broken_index_entry],
|
|
13
|
-
"Completeness" => %i[stub missing_title missing_description
|
|
14
|
-
"Freshness" => %i[stale],
|
|
15
|
-
"Provenance" => %i[uncited_external
|
|
16
|
-
"
|
|
13
|
+
"Completeness" => %i[stub missing_title missing_description missing_generated],
|
|
14
|
+
"Freshness" => %i[expired stale],
|
|
15
|
+
"Provenance" => %i[uncited_external broken_source unattributed_claim unused_source unprefixed_actor],
|
|
16
|
+
"Attestation" => %i[incomplete_computation broken_attestation_ref],
|
|
17
|
+
"Migration" => %i[legacy_timestamp legacy_citations],
|
|
18
|
+
"Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link log_order]
|
|
17
19
|
}.freeze
|
|
18
20
|
|
|
21
|
+
# `--today` reason for existing, in one line: a CI consumer wanting a
|
|
22
|
+
# reproducible `expired` report needs a fixed clock. Semver-stable API,
|
|
23
|
+
# documented in the skill's cli.md; absent, the CLI reads the wall clock —
|
|
24
|
+
# the library default (`Linter.call` with no `today:`) reads none at all.
|
|
25
|
+
|
|
19
26
|
def self.id
|
|
20
27
|
:lint
|
|
21
28
|
end
|
|
@@ -26,20 +33,24 @@ module OKF
|
|
|
26
33
|
|
|
27
34
|
def self.help_rows
|
|
28
35
|
[
|
|
29
|
-
[ "lint <dir|@slug> [--json] [--fail-on
|
|
36
|
+
[ "lint <dir|@slug> [--json] [--fail-on LEVEL] [...]", "report curation-quality issues" ]
|
|
30
37
|
]
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def call(argv)
|
|
34
|
-
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil,
|
|
41
|
+
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil,
|
|
42
|
+
today: nil, only: nil, except: nil, fail_on: :never }
|
|
35
43
|
parser = OptionParser.new do |o|
|
|
36
|
-
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--
|
|
44
|
+
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--today DATE] " \
|
|
45
|
+
"[--only a,b] [--except a,b] [--fail-on never|info|warn]"
|
|
37
46
|
json_flags(o, options, "emit a JSON report")
|
|
38
47
|
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
39
|
-
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01
|
|
48
|
+
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01,",
|
|
49
|
+
"or a full 2026-01-01T09:00:00Z timestamp)") { |v| options[:stale_after] = v }
|
|
50
|
+
o.on("--today DATE", "the day `expired` compares stale_after against (default: today)") { |v| options[:today] = v }
|
|
40
51
|
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
41
52
|
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
42
|
-
o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
53
|
+
o.on("--fail-on LEVEL", %w[never info warn], "exit 1 when a finding at LEVEL exists (never | info | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
43
54
|
help_flag(o)
|
|
44
55
|
end
|
|
45
56
|
dir = positional_dir(parser, argv) or return 2
|
|
@@ -56,15 +67,42 @@ module OKF
|
|
|
56
67
|
return 2
|
|
57
68
|
end
|
|
58
69
|
|
|
70
|
+
today = parse_today(options[:today])
|
|
71
|
+
if today == :invalid
|
|
72
|
+
@err.puts "error: invalid --today `#{options[:today]}` (use YYYY-MM-DD)"
|
|
73
|
+
return 2
|
|
74
|
+
end
|
|
75
|
+
|
|
59
76
|
folder = OKF::Bundle::Folder.load(dir)
|
|
60
|
-
report = folder.lint(min_body: options[:min_body], stale_before: stale_before,
|
|
77
|
+
report = folder.lint(min_body: options[:min_body], stale_before: stale_before, today: today,
|
|
78
|
+
only: options[:only], except: options[:except])
|
|
61
79
|
note_skipped(report.stats[:skipped])
|
|
62
80
|
options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
|
|
63
|
-
options[:fail_on]
|
|
81
|
+
lint_exit(options[:fail_on], report)
|
|
64
82
|
end
|
|
65
83
|
|
|
66
84
|
private
|
|
67
85
|
|
|
86
|
+
def lint_exit(fail_on, report)
|
|
87
|
+
case fail_on
|
|
88
|
+
when :warn then report.warnings.any? ? 1 : 0
|
|
89
|
+
when :info then report.findings.any? ? 1 : 0
|
|
90
|
+
else 0
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The CLI always supplies a clock — Date.today unless --today pins one —
|
|
95
|
+
# so `okf lint` reports `expired` out of the box while the pure library
|
|
96
|
+
# default runs no clock check at all (and confesses via skipped_checks).
|
|
97
|
+
def parse_today(value)
|
|
98
|
+
return Date.today if value.nil?
|
|
99
|
+
return :invalid unless value.match?(Concept::ISO_DATE)
|
|
100
|
+
|
|
101
|
+
Date.iso8601(value)
|
|
102
|
+
rescue ArgumentError
|
|
103
|
+
:invalid
|
|
104
|
+
end
|
|
105
|
+
|
|
68
106
|
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
69
107
|
# Time so the pure Linter never reads the clock. nil when unset, :invalid on a
|
|
70
108
|
# bad value.
|
|
@@ -74,8 +112,14 @@ module OKF
|
|
|
74
112
|
if (match = value.match(/\A(\d+)([dw])\z/))
|
|
75
113
|
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
76
114
|
Time.now - (days * 86_400)
|
|
77
|
-
|
|
115
|
+
elsif value.match?(Concept::ISO_CUTOFF)
|
|
116
|
+
# Not --today's grammar: that one is a calendar day, this one a
|
|
117
|
+
# moment, so a full `generated.at` timestamp is accepted and reduced
|
|
118
|
+
# to its date. What both refuse is the same — the basic and week
|
|
119
|
+
# spellings Date.iso8601 would silently reinterpret.
|
|
78
120
|
Date.iso8601(value).to_time
|
|
121
|
+
else
|
|
122
|
+
:invalid
|
|
79
123
|
end
|
|
80
124
|
rescue ArgumentError
|
|
81
125
|
:invalid
|
|
@@ -87,6 +131,8 @@ module OKF
|
|
|
87
131
|
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
88
132
|
summary = lint_summary(stats)
|
|
89
133
|
@out.puts " #{summary}" unless summary.empty?
|
|
134
|
+
posture = lint_posture(stats)
|
|
135
|
+
@out.puts " #{posture}" unless posture.empty?
|
|
90
136
|
|
|
91
137
|
LINT_CATEGORIES.each do |name, checks|
|
|
92
138
|
findings = report.findings.select { |finding| checks.include?(finding[:check]) }
|
|
@@ -99,6 +145,8 @@ module OKF
|
|
|
99
145
|
end
|
|
100
146
|
end
|
|
101
147
|
|
|
148
|
+
skipped = Array(report.stats[:skipped_checks])
|
|
149
|
+
@out.puts " skipped: #{skipped.join(", ")} (no clock or cutoff supplied)" unless skipped.empty?
|
|
102
150
|
@out.puts
|
|
103
151
|
@out.puts " #{lint_verdict(report)}"
|
|
104
152
|
end
|
|
@@ -120,6 +168,16 @@ module OKF
|
|
|
120
168
|
parts.join(" ")
|
|
121
169
|
end
|
|
122
170
|
|
|
171
|
+
# The bundle's trust/status posture, printed only when there is a bundle
|
|
172
|
+
# to have one (an empty bundle has no distribution worth a line).
|
|
173
|
+
def lint_posture(stats)
|
|
174
|
+
return "" if stats[:concepts].zero?
|
|
175
|
+
|
|
176
|
+
trust = stats[:trust].map { |tier, count| "#{tier} #{count}" }.join(", ")
|
|
177
|
+
status = stats[:status].map { |value, count| "#{value} #{count}" }.join(", ")
|
|
178
|
+
"trust: #{trust} status: #{status}"
|
|
179
|
+
end
|
|
180
|
+
|
|
123
181
|
def lint_glyph(finding)
|
|
124
182
|
finding[:severity] == :warn ? paint("! warn", 33) : "· info"
|
|
125
183
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The §6.3 inventory: every file under `references/`, which concepts cite
|
|
6
|
+
# it through the §6.2 path-valued fields, and the pointers into
|
|
7
|
+
# `references/` that resolve to nothing — with the leading-slash fix named
|
|
8
|
+
# when a bare path written from a subdirectory is what missed. An advisory
|
|
9
|
+
# read (exit 0): what a curator acts on lives in the output, never the
|
|
10
|
+
# status.
|
|
11
|
+
class References < Command
|
|
12
|
+
def self.id
|
|
13
|
+
:references
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.group
|
|
17
|
+
:read
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.help_rows
|
|
21
|
+
[
|
|
22
|
+
[ "references <dir|@slug> [--json]", "inventory references/ files and the concepts citing them" ]
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(argv)
|
|
27
|
+
options = { json: false }
|
|
28
|
+
parser = OptionParser.new do |o|
|
|
29
|
+
o.banner = "Usage: okf references <dir|@slug> [--json]"
|
|
30
|
+
json_flags(o, options, "emit the inventory as JSON")
|
|
31
|
+
projection_flags(o, options)
|
|
32
|
+
help_flag(o)
|
|
33
|
+
end
|
|
34
|
+
dir = positional_dir(parser, argv) or return 2
|
|
35
|
+
|
|
36
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
37
|
+
report_skipped(folder)
|
|
38
|
+
references = folder.references
|
|
39
|
+
return print_references_json(dir, references, options) if options[:json]
|
|
40
|
+
|
|
41
|
+
print_references(dir, references)
|
|
42
|
+
0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def print_references(dir, references)
|
|
48
|
+
entries = references.entries
|
|
49
|
+
@out.puts "References — #{bundle_label(dir)} (#{entries.size} #{pluralize(entries.size, "file")})"
|
|
50
|
+
@out.puts " (none)" if entries.empty?
|
|
51
|
+
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
52
|
+
width = group.map { |entry| File.basename(entry[:path]).length }.max
|
|
53
|
+
@out.puts
|
|
54
|
+
@out.puts " #{folder}/"
|
|
55
|
+
group.each do |entry|
|
|
56
|
+
@out.puts " #{File.basename(entry[:path]).ljust(width)} #{entry_note(entry)}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
print_dangling(references.dangling)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def entry_note(entry)
|
|
63
|
+
marker = entry[:kind] == "concept" ? "[concept] " : ""
|
|
64
|
+
cited = entry[:referenced_by]
|
|
65
|
+
return "#{marker}(unreferenced)" if cited.empty?
|
|
66
|
+
|
|
67
|
+
"#{marker}← #{cited.map { |ref| "#{ref[:id]} (#{ref[:field]})" }.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def print_dangling(rows)
|
|
71
|
+
return if rows.empty?
|
|
72
|
+
|
|
73
|
+
@out.puts
|
|
74
|
+
@out.puts " dangling pointers:"
|
|
75
|
+
rows.each do |row|
|
|
76
|
+
@out.puts " #{row[:id]} — #{row[:field]}: #{row[:raw]}"
|
|
77
|
+
line = " resolves to #{row[:resolved]}, which does not exist"
|
|
78
|
+
# hint is only ever the leading-slash fix, so the human line can name
|
|
79
|
+
# the spelling that would have hit without restating the sentence.
|
|
80
|
+
line += " — /#{row[:raw]} does (missing leading slash?)" if row[:hint]
|
|
81
|
+
@out.puts line
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def print_references_json(dir, references, options)
|
|
86
|
+
rows = references.entries.map do |entry|
|
|
87
|
+
{ "path" => entry[:path], "dir" => entry[:dir], "kind" => entry[:kind],
|
|
88
|
+
"referenced_by" => entry[:referenced_by].map { |ref| stringify(ref) } }
|
|
89
|
+
end
|
|
90
|
+
dangling = references.dangling.map { |row| stringify(row) }
|
|
91
|
+
emit_list_json(dir, "references", rows, options, "dangling" => dangling)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
register(References)
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/okf/cli/search.rb
CHANGED
|
@@ -34,7 +34,8 @@ module OKF
|
|
|
34
34
|
def call(argv)
|
|
35
35
|
options = { json: false, regexp: false, fuzzy: false, engine: nil }
|
|
36
36
|
parser = OptionParser.new do |o|
|
|
37
|
-
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy]
|
|
37
|
+
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] " \
|
|
38
|
+
"[--in FIELDS] [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
|
|
38
39
|
search_engine_note(o)
|
|
39
40
|
json_flags(o, options, "emit the matches as JSON")
|
|
40
41
|
projection_flags(o, options)
|
|
@@ -209,16 +210,30 @@ module OKF
|
|
|
209
210
|
#
|
|
210
211
|
# Filters stay per-bundle — they are per-folder questions — so they apply to
|
|
211
212
|
# the merged rows by (slug, id) afterwards.
|
|
213
|
+
#
|
|
214
|
+
# The one thing that is *not* a per-folder question is what `--dir root`
|
|
215
|
+
# means. The alias yields to a directory that really carries the name, so
|
|
216
|
+
# resolving it inside this loop made one flag mean two things in one
|
|
217
|
+
# ranking: the `root/` subtree where a bundle has one, the bundle root
|
|
218
|
+
# where it does not, merged with nothing in the output saying so. The
|
|
219
|
+
# served set answers it once, and a bundle without the directory then
|
|
220
|
+
# matches nothing — which is what `--dir` already does everywhere for a
|
|
221
|
+
# directory a bundle lacks.
|
|
212
222
|
def multi_search(pairs, terms, options)
|
|
213
|
-
|
|
214
|
-
keeps = {}
|
|
215
|
-
total = 0
|
|
216
|
-
pairs.each do |slug, dir|
|
|
223
|
+
folders = pairs.map do |slug, dir|
|
|
217
224
|
folder = OKF::Bundle::Folder.load(dir)
|
|
218
225
|
report_skipped(folder)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
226
|
+
[ slug, folder ]
|
|
227
|
+
end
|
|
228
|
+
total = folders.reduce(0) { |sum, (_, folder)| sum + folder.bundle.concepts.size }
|
|
229
|
+
bundles = folders.map { |slug, folder| [ slug, folder.bundle ] }
|
|
230
|
+
# The served set's directories, only when a flag will consult them —
|
|
231
|
+
# the alias is resolved once across the whole run (see filter_ids),
|
|
232
|
+
# and an unfiltered search never pays the walk.
|
|
233
|
+
dirs = options[:dir] || options[:area] ? folders.flat_map { |_, folder| folder.directories }.uniq : nil
|
|
234
|
+
keeps = {}
|
|
235
|
+
folders.each do |slug, folder|
|
|
236
|
+
keep = filter_ids(folder, options, dirs)
|
|
222
237
|
keeps[slug] = keep unless keep.nil?
|
|
223
238
|
end
|
|
224
239
|
rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
|
data/lib/okf/cli/stats.rb
CHANGED
|
@@ -29,51 +29,15 @@ module OKF
|
|
|
29
29
|
|
|
30
30
|
folder = OKF::Bundle::Folder.load(dir)
|
|
31
31
|
report_skipped(folder)
|
|
32
|
-
stats =
|
|
32
|
+
stats = folder.stats
|
|
33
33
|
options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
|
|
34
34
|
0
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|
|
38
38
|
|
|
39
|
-
# Bundle
|
|
40
|
-
|
|
41
|
-
graph = folder.graph(minimal: true)
|
|
42
|
-
entries = folder.catalog
|
|
43
|
-
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
44
|
-
by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
45
|
-
by_dir = directory_counts(folder)
|
|
46
|
-
{
|
|
47
|
-
concepts: entries.size,
|
|
48
|
-
dirs: by_dir.size,
|
|
49
|
-
top_dirs: by_top_dir.size,
|
|
50
|
-
types: by_type.size,
|
|
51
|
-
cross_links: graph.edges.size,
|
|
52
|
-
tags: graph.tag_index.size,
|
|
53
|
-
by_type: by_type,
|
|
54
|
-
by_dir: by_dir,
|
|
55
|
-
by_top_dir: by_top_dir
|
|
56
|
-
}
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Every directory the bundle has, with the concepts that live *directly* in
|
|
60
|
-
# it. Read off Bundle#directory_index — the same map `okf dirs` lists and
|
|
61
|
-
# `--dir` is answered against — rather than off the catalog, which knows
|
|
62
|
-
# only the directories that happen to hold a concept. Grouping the catalog
|
|
63
|
-
# made `stats` and `dirs` report different totals for one bundle, and left
|
|
64
|
-
# an addressable directory out of by_dir entirely: `--dir deeply` answers,
|
|
65
|
-
# but nothing in `stats` said `deeply` was there to ask about.
|
|
66
|
-
#
|
|
67
|
-
# A directory holding nothing directly therefore appears at 0. That is the
|
|
68
|
-
# honest reading — it is the same zero `okf dirs` prints in its Concepts
|
|
69
|
-
# column — and it keeps `dirs` equal to `by_dir.size`. Ties break by path so
|
|
70
|
-
# the order is total, not whatever the sort happened to leave.
|
|
71
|
-
def directory_counts(folder)
|
|
72
|
-
folder.directory_index
|
|
73
|
-
.map { |entry| [ entry[:dir], entry[:count] ] }
|
|
74
|
-
.sort_by { |dir, count| [ -count, dir ] }.to_h
|
|
75
|
-
end
|
|
76
|
-
|
|
39
|
+
# The rollup itself is Bundle#stats — one home, shared with the MCP
|
|
40
|
+
# shell, after the by_dir subtlety diverged once when hand-copied.
|
|
77
41
|
def print_stats(dir, stats)
|
|
78
42
|
@out.puts "Stats — #{bundle_label(dir)}"
|
|
79
43
|
@out.puts
|
data/lib/okf/cli/tags.rb
CHANGED
|
@@ -23,7 +23,7 @@ module OKF
|
|
|
23
23
|
def call(argv)
|
|
24
24
|
options = { json: false, by: nil }
|
|
25
25
|
parser = OptionParser.new do |o|
|
|
26
|
-
o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--json]"
|
|
26
|
+
o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--status S] [--trust T] [--json]"
|
|
27
27
|
json_flags(o, options, "emit the tag index as JSON")
|
|
28
28
|
o.on("--by DIM", %w[type dir area], "group the tags by a concept dimension (type | dir)") do |v|
|
|
29
29
|
options[:by] = v.to_sym
|
|
@@ -50,52 +50,15 @@ module OKF
|
|
|
50
50
|
report_skipped(folder)
|
|
51
51
|
graph = folder.graph(minimal: true)
|
|
52
52
|
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
53
|
-
groups = tag_groups(
|
|
53
|
+
groups = folder.tag_groups(by: options[:by],
|
|
54
|
+
entries: filter_entries(folder.catalog, options, dir_scope(folder, options)))
|
|
54
55
|
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
55
56
|
0
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
# makes a tag's spread — local to one group, or cutting across several —
|
|
62
|
-
# readable without cross-referencing the groups by hand.
|
|
63
|
-
def tag_groups(tag_index, folder, options)
|
|
64
|
-
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
65
|
-
groups = {}
|
|
66
|
-
totals = Hash.new(0)
|
|
67
|
-
tag_index.each do |tag, ids|
|
|
68
|
-
ids.each do |id|
|
|
69
|
-
entry = by_id[id]
|
|
70
|
-
next if entry.nil?
|
|
71
|
-
|
|
72
|
-
key = group_key(entry, options[:by])
|
|
73
|
-
((groups[key] ||= {})[tag] ||= []) << id
|
|
74
|
-
totals[tag] += 1
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
groups.map do |key, tags|
|
|
78
|
-
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
|
|
79
|
-
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
80
|
-
[ key, rows ]
|
|
81
|
-
end.sort_by(&:first)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
85
|
-
def entry_type(entry)
|
|
86
|
-
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# The group a concept falls in, in its *stored* spelling — `.` for the root
|
|
90
|
-
# under --by dir, never "(root)". The human label is applied at print time,
|
|
91
|
-
# so the JSON and the table cannot disagree about which one is the data.
|
|
92
|
-
def group_key(entry, dim)
|
|
93
|
-
case dim
|
|
94
|
-
when :type then entry_type(entry)
|
|
95
|
-
when :dir then entry[:dir]
|
|
96
|
-
else entry[:top_dir]
|
|
97
|
-
end
|
|
98
|
-
end
|
|
59
|
+
# The grouping itself is Bundle#tag_groups — one home, shared with the
|
|
60
|
+
# MCP shell; this layer keeps only what is the CLI's own, the filter
|
|
61
|
+
# narrowing passed through `entries:`.
|
|
99
62
|
|
|
100
63
|
# `.` prints "(root)" bare; every other dir carries the trailing slash that
|
|
101
64
|
# says it is one. The deprecated --by area already stores "(root)" itself.
|
data/lib/okf/cli/types.rb
CHANGED
|
@@ -21,7 +21,7 @@ module OKF
|
|
|
21
21
|
def call(argv)
|
|
22
22
|
options = { json: false }
|
|
23
23
|
parser = OptionParser.new do |o|
|
|
24
|
-
o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--json]"
|
|
24
|
+
o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--status S] [--trust T] [--json]"
|
|
25
25
|
json_flags(o, options, "emit the type index as JSON")
|
|
26
26
|
filter_flags(o, options, :area, :tag)
|
|
27
27
|
help_flag(o)
|