okf 1.13.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 +195 -0
- data/README.md +25 -6
- data/lib/okf/bundle/folder.rb +27 -1
- data/lib/okf/bundle/graph.rb +12 -3
- data/lib/okf/bundle/linter.rb +470 -47
- data/lib/okf/bundle/reader.rb +10 -4
- 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 +105 -4
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +27 -8
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/index.rb +1 -1
- data/lib/okf/cli/lint.rb +70 -12
- data/lib/okf/cli/references.rb +97 -0
- data/lib/okf/cli/search.rb +2 -1
- 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.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/render/graph/template.html.erb +173 -41
- data/lib/okf/render/graph.rb +11 -3
- 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 +155 -42
- 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 +22 -2
- metadata +5 -1
data/lib/okf/bundle.rb
CHANGED
|
@@ -66,6 +66,28 @@ module OKF
|
|
|
66
66
|
entry ? entry.content.to_s : ""
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# The spec version the root index.md declares (§12), as the producer wrote
|
|
70
|
+
# it, or nil when it declares none — which §12 explicitly permits, so nil is
|
|
71
|
+
# an answer rather than a fault, and a consumer that must name a version
|
|
72
|
+
# should say "conformant" rather than guess one.
|
|
73
|
+
#
|
|
74
|
+
# Stringified and stripped for the reason the validator compares that way:
|
|
75
|
+
# an unquoted `okf_version: 0.2` is a Psych Float, and a consumer switching
|
|
76
|
+
# on it must not be handed 0.2 the number. Public because a version stated
|
|
77
|
+
# on screen is otherwise a literal — which is how a reader gets told "v0.1"
|
|
78
|
+
# about a bundle that declares 0.2. Unparseable frontmatter is the
|
|
79
|
+
# validator's error to report, not this reader's to raise.
|
|
80
|
+
def okf_version
|
|
81
|
+
content = reserved_content("index.md")
|
|
82
|
+
return nil unless content.match?(/\A---[ \t]*\n/)
|
|
83
|
+
|
|
84
|
+
frontmatter, = Markdown::Frontmatter.parse(content)
|
|
85
|
+
declared = frontmatter["okf_version"]
|
|
86
|
+
OKF.blank?(declared) ? nil : declared.to_s.strip
|
|
87
|
+
rescue Markdown::Frontmatter::ParseError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
69
91
|
# ── id ↔ path (the single source of "which concept an id names") ──
|
|
70
92
|
# A concept's id may be a frontmatter `id`, so it is not derivable from the path
|
|
71
93
|
# alone. These maps let the shell resolve an id back to its file (the server's
|
|
@@ -119,8 +141,13 @@ module OKF
|
|
|
119
141
|
type: concept.type.to_s,
|
|
120
142
|
description: concept.description.to_s,
|
|
121
143
|
tags: Array(concept.tags).map(&:to_s),
|
|
122
|
-
|
|
123
|
-
|
|
144
|
+
generated_at: iso8601(concept.generated_at),
|
|
145
|
+
generated_by: concept.generated_by&.to_s,
|
|
146
|
+
generated: concept.declared_generated?,
|
|
147
|
+
trust: concept.trust,
|
|
148
|
+
status: concept.declared_status&.to_s,
|
|
149
|
+
stale_after: iso8601(concept.stale_after),
|
|
150
|
+
sources: concept.sources.length,
|
|
124
151
|
backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
|
|
125
152
|
dir: OKF.dir_of(id),
|
|
126
153
|
top_dir: top_dir_of(id),
|
|
@@ -148,14 +175,71 @@ module OKF
|
|
|
148
175
|
end.sort_by { |row| [ -row[:inbound], row[:id] ] }
|
|
149
176
|
end
|
|
150
177
|
|
|
151
|
-
#
|
|
178
|
+
# Bundle-level rollups — concepts, dirs, types, links, tags, with the
|
|
179
|
+
# by_type/by_dir/by_top_dir distributions. One home, shared by `okf stats`
|
|
180
|
+
# and the MCP stats tool, because the by_dir subtlety already diverged once
|
|
181
|
+
# when hand-copied: it reads Bundle#directory_index (the map `--dir` is
|
|
182
|
+
# answered against), so a directory holding nothing directly appears at 0
|
|
183
|
+
# rather than disappearing, and `dirs` equals `by_dir.size`.
|
|
184
|
+
# Note the recorded split: by_dir is the disk, by_top_dir rolls up the id.
|
|
185
|
+
def stats
|
|
186
|
+
minimal = graph(minimal: true)
|
|
187
|
+
entries = catalog
|
|
188
|
+
by_type = minimal.type_index.transform_values(&:size).sort_by { |_, size| -size }.to_h
|
|
189
|
+
by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, size| -size }.to_h
|
|
190
|
+
by_dir = directory_index.map { |entry| [ entry[:dir], entry[:count] ] }
|
|
191
|
+
.sort_by { |dir, count| [ -count, dir ] }.to_h
|
|
192
|
+
{
|
|
193
|
+
concepts: entries.size,
|
|
194
|
+
dirs: by_dir.size,
|
|
195
|
+
top_dirs: by_top_dir.size,
|
|
196
|
+
types: by_type.size,
|
|
197
|
+
cross_links: minimal.edges.size,
|
|
198
|
+
tags: minimal.tag_index.size,
|
|
199
|
+
by_type: by_type,
|
|
200
|
+
by_dir: by_dir,
|
|
201
|
+
by_top_dir: by_top_dir
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The tag index re-cut per concept dimension — the vocabulary-curation
|
|
206
|
+
# view: [ [ group-key, rows ], … ] with each row carrying `count` (within
|
|
207
|
+
# the group) beside `total` (across the set), so a tag local to one group
|
|
208
|
+
# and one cutting across several read differently without cross-referencing
|
|
209
|
+
# by hand. `by:` is :type (blank folds to "Untyped", matching the graph),
|
|
210
|
+
# :dir (the stored spelling — `.` for the root), or anything else for the
|
|
211
|
+
# deprecated first-segment cut. `entries:` narrows the concepts counted —
|
|
212
|
+
# the CLI passes its filtered catalog; default is everything.
|
|
213
|
+
def tag_groups(by:, entries: nil)
|
|
214
|
+
entries ||= catalog
|
|
215
|
+
by_id = entries.map { |entry| [ entry[:id], entry ] }.to_h
|
|
216
|
+
groups = {}
|
|
217
|
+
totals = Hash.new(0)
|
|
218
|
+
graph(minimal: true).tag_index.each do |tag, ids|
|
|
219
|
+
ids.each do |id|
|
|
220
|
+
entry = by_id[id]
|
|
221
|
+
next if entry.nil?
|
|
222
|
+
|
|
223
|
+
key = tag_group_key(entry, by)
|
|
224
|
+
((groups[key] ||= {})[tag] ||= []) << id
|
|
225
|
+
totals[tag] += 1
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
groups.map do |key, tags|
|
|
229
|
+
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
|
|
230
|
+
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
231
|
+
[ key, rows ]
|
|
232
|
+
end.sort_by(&:first)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# The progressive-disclosure map (spec §8): one entry per directory that holds
|
|
152
236
|
# concepts or carries an index.md, sorted with the root (".") first. Each entry
|
|
153
237
|
# gives the authored index body (frontmatter stripped) when an index.md is
|
|
154
238
|
# present, a type/tag rollup over the concepts that live *directly* in the
|
|
155
239
|
# directory, its immediate child directories, and the concept listing an
|
|
156
240
|
# index.md there would enumerate. A directory with concepts but no index.md has
|
|
157
241
|
# `present: false` and still carries the listing, so a consumer can synthesize
|
|
158
|
-
# the map on the fly (§
|
|
242
|
+
# the map on the fly (§8 permits exactly that). Grouped by the concept's file
|
|
159
243
|
# path — index files are physical directory listings, so a custom frontmatter
|
|
160
244
|
# `id` must not move a concept out of the directory it lives in. Pure: derived
|
|
161
245
|
# from the concepts and the reserved index text, no disk. Shared by the
|
|
@@ -205,6 +289,12 @@ module OKF
|
|
|
205
289
|
|
|
206
290
|
private
|
|
207
291
|
|
|
292
|
+
# The one temporal-serialization rule, shared with /node/meta — see
|
|
293
|
+
# OKF.iso8601.
|
|
294
|
+
def iso8601(value)
|
|
295
|
+
OKF.iso8601(value)
|
|
296
|
+
end
|
|
297
|
+
|
|
208
298
|
# A concept's top-level dir, derived from its id — the first path segment, the
|
|
209
299
|
# same derivation the catalog exposes, so every grouped view labels the bundle
|
|
210
300
|
# root "(root)". OKF.dir_of keeps the levels this one rolls up.
|
|
@@ -233,6 +323,17 @@ module OKF
|
|
|
233
323
|
dirs.keys.sort_by { |dir| dir == "." ? "" : dir }
|
|
234
324
|
end
|
|
235
325
|
|
|
326
|
+
# The group a concept falls in, in its *stored* spelling — `.` for the
|
|
327
|
+
# root under :dir, never "(root)": the human label is applied at print
|
|
328
|
+
# time, so the JSON and a table cannot disagree about which is the data.
|
|
329
|
+
def tag_group_key(entry, dim)
|
|
330
|
+
case dim
|
|
331
|
+
when :type then OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
332
|
+
when :dir then entry[:dir]
|
|
333
|
+
else entry[:top_dir]
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
236
337
|
# { value => count }, ordered by count descending then value.
|
|
237
338
|
def tally(values)
|
|
238
339
|
counts = values.each_with_object(Hash.new(0)) { |value, acc| acc[value] += 1 }
|
data/lib/okf/cli/catalog.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 catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
|
|
25
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--status S] [--trust T] [--json]"
|
|
26
26
|
json_flags(o, options, "emit the catalog as JSON")
|
|
27
27
|
projection_flags(o, options)
|
|
28
28
|
filter_flags(o, options, :type, :area, :tag)
|
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,6 +193,17 @@ 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`
|
|
@@ -206,14 +222,17 @@ module OKF
|
|
|
206
222
|
# shape. An out-of-tree caller that never learned the third argument gets
|
|
207
223
|
# exactly the resolution it was written against — the alias folds with no
|
|
208
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).
|
|
209
229
|
def filter_entries(entries, options, dirs = nil)
|
|
210
230
|
area = options[:area] && fold_area(options[:area], dirs)
|
|
211
231
|
base = options[:dir] && fold_dir(options[:dir], dirs)
|
|
212
232
|
entries.select do |entry|
|
|
213
|
-
(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
(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])
|
|
217
236
|
end
|
|
218
237
|
end
|
|
219
238
|
|
|
@@ -234,9 +253,9 @@ module OKF
|
|
|
234
253
|
# and #fold for a stored one. A stored dir is never an alias — that is the
|
|
235
254
|
# distinction the old signature could not make, and it is what had a row
|
|
236
255
|
# named `root` counting the bundle root's subtree instead of its own.
|
|
256
|
+
# The comparison itself is Bundle::RowFilter's (fold is idempotent).
|
|
237
257
|
def under_dir?(entry_dir, path)
|
|
238
|
-
|
|
239
|
-
entry == path || entry.start_with?("#{path}/")
|
|
258
|
+
Bundle::RowFilter.under_dir?(entry_dir, path)
|
|
240
259
|
end
|
|
241
260
|
|
|
242
261
|
def fold(value)
|
|
@@ -429,12 +448,12 @@ module OKF
|
|
|
429
448
|
# into a single ranking with nothing saying so. One invocation, one
|
|
430
449
|
# meaning — see Search#multi_search.
|
|
431
450
|
def filter_ids(folder, options, dirs = nil)
|
|
432
|
-
return nil if
|
|
451
|
+
return nil if FILTER_KEYS.none? { |key| options[key] }
|
|
433
452
|
|
|
434
453
|
filter_entries(folder.catalog, options, dirs || dir_scope(folder, options)).map { |entry| entry[:id] }
|
|
435
454
|
end
|
|
436
455
|
|
|
437
|
-
# §
|
|
456
|
+
# §11 best-effort: the graph is built from concepts that parse. Surface any that
|
|
438
457
|
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
439
458
|
# than dropping them silently.
|
|
440
459
|
def report_skipped(folder)
|
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)
|
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`
|
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)
|
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, dir_scope(folder, 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)
|