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/validate.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class CLI
|
|
5
|
-
# The §
|
|
5
|
+
# The §11 conformance judge: is this legal OKF? Binary and tolerant — it is
|
|
6
6
|
# forbidden from failing a bundle over a broken link or a missing optional
|
|
7
7
|
# field, which is lint's job. Exit 1 when non-conformant.
|
|
8
8
|
class Validate < Command
|
|
@@ -16,7 +16,7 @@ module OKF
|
|
|
16
16
|
|
|
17
17
|
def self.help_rows
|
|
18
18
|
[
|
|
19
|
-
[ "validate <dir|@slug> [--json]", "check OKF
|
|
19
|
+
[ "validate <dir|@slug> [--json]", "check OKF v#{OKF::SPEC_VERSION} conformance" ]
|
|
20
20
|
]
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -38,7 +38,7 @@ module OKF
|
|
|
38
38
|
|
|
39
39
|
def print_validation(dir, result)
|
|
40
40
|
counts = result.counts
|
|
41
|
-
@out.puts "OKF
|
|
41
|
+
@out.puts "OKF v#{OKF::SPEC_VERSION} conformance — #{bundle_label(dir)}"
|
|
42
42
|
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
43
43
|
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
44
44
|
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
data/lib/okf/cli.rb
CHANGED
|
@@ -39,8 +39,10 @@ module OKF
|
|
|
39
39
|
# would let `--fields slug` pass on a search whose rows have none, and hand
|
|
40
40
|
# back an empty object per match under a count that says otherwise.
|
|
41
41
|
"matches_by_ref" => %w[slug id title type dir top_dir tags matched score snippet],
|
|
42
|
-
"concepts" => %w[id title type description tags
|
|
42
|
+
"concepts" => %w[id title type description tags generated_at generated_by generated trust status
|
|
43
|
+
stale_after sources backlog_ref dir top_dir links_out links_in],
|
|
43
44
|
"files" => %w[path id dir type title description],
|
|
45
|
+
"references" => %w[path dir kind referenced_by],
|
|
44
46
|
"directories" => %w[dir ancestor index_path present synthesized count types tags subdirs body listing],
|
|
45
47
|
"dirs" => %w[dir ancestor count subtree subdirs],
|
|
46
48
|
"bundles" => %w[slug title dir mount default missing]
|
|
@@ -505,6 +507,7 @@ require "okf/cli/stats"
|
|
|
505
507
|
require "okf/cli/types"
|
|
506
508
|
require "okf/cli/tags"
|
|
507
509
|
require "okf/cli/files"
|
|
510
|
+
require "okf/cli/references"
|
|
508
511
|
require "okf/cli/catalog"
|
|
509
512
|
require "okf/cli/graph"
|
|
510
513
|
|
data/lib/okf/concept/file.rb
CHANGED
|
@@ -14,6 +14,13 @@ module OKF
|
|
|
14
14
|
#
|
|
15
15
|
# NOTE: this class is named File, which shadows Ruby's File inside the
|
|
16
16
|
# OKF::Concept namespace — every filesystem call here uses ::File explicitly.
|
|
17
|
+
#
|
|
18
|
+
# absolute_path guards the *name* lexically (Path.join_under!), which is all a
|
|
19
|
+
# write needs — the file may not exist yet. A read has more to prove: the file
|
|
20
|
+
# is on disk now, so it may be a symlink whose name is inside the root but
|
|
21
|
+
# whose target is not, and File.expand_path does not resolve links. So #read
|
|
22
|
+
# goes through SafeRead, which realpath-resolves and refuses a target outside
|
|
23
|
+
# the root, closing the same escape Bundle::Reader closes on the bulk read.
|
|
17
24
|
class File
|
|
18
25
|
attr_reader :root, :path, :concept
|
|
19
26
|
|
|
@@ -53,11 +60,19 @@ module OKF
|
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def reload
|
|
56
|
-
|
|
57
|
-
frontmatter, body = Markdown::Frontmatter.parse(content)
|
|
63
|
+
frontmatter, body = Markdown::Frontmatter.parse(read)
|
|
58
64
|
@concept = Concept.new(path: @path, frontmatter: frontmatter, body: body)
|
|
59
65
|
self
|
|
60
66
|
end
|
|
67
|
+
|
|
68
|
+
# The file's own bytes, refused if the resolved target escapes the root by
|
|
69
|
+
# symlink. This is the guarded read a caller that wants the raw markdown
|
|
70
|
+
# (not a re-serialized `concept.to_markdown`) must use instead of reading
|
|
71
|
+
# #absolute_path itself — that path guards the *name* lexically, which a
|
|
72
|
+
# write needs but a read does not, since the file exists and may be a link.
|
|
73
|
+
def read
|
|
74
|
+
SafeRead.read!(@root, absolute_path)
|
|
75
|
+
end
|
|
61
76
|
end
|
|
62
77
|
end
|
|
63
78
|
end
|
data/lib/okf/concept.rb
CHANGED
|
@@ -8,12 +8,114 @@ module OKF
|
|
|
8
8
|
RESERVED_FILENAMES = %w[index.md log.md].freeze
|
|
9
9
|
|
|
10
10
|
# The lint checks that apply to a single concept out of bundle context. The
|
|
11
|
-
# rest (orphan, backlog, duplicate_title, …) need the whole
|
|
11
|
+
# rest (orphan, backlog, duplicate_title, broken_source, …) need the whole
|
|
12
|
+
# bundle. Linter#selected_checks intersects silently, so a stale id here
|
|
13
|
+
# quietly stops Concept#lint running the check — a test pins the list
|
|
14
|
+
# against Linter::CHECKS.
|
|
12
15
|
CONCEPT_SCOPED_CHECKS = %i[
|
|
13
|
-
stub missing_title missing_description
|
|
14
|
-
uncited_external
|
|
16
|
+
stub missing_title missing_description missing_generated
|
|
17
|
+
expired uncited_external unattributed_claim unused_source
|
|
18
|
+
unprefixed_actor incomplete_computation
|
|
19
|
+
legacy_timestamp legacy_citations
|
|
20
|
+
self_link unused_reference_def undefined_reference
|
|
15
21
|
].freeze
|
|
16
22
|
|
|
23
|
+
# The spec versions this gem has a reader for, newest first — what a root
|
|
24
|
+
# `index.md`'s `okf_version` is checked against (§12). A document is always
|
|
25
|
+
# read as the newest version, without sniffing its shape: §13.1 makes the
|
|
26
|
+
# legacy fallbacks part of v0.2's own reading rule, so a v0.2 reader handed
|
|
27
|
+
# a v0.1 document is the correct reader for it.
|
|
28
|
+
KNOWN_SPEC_VERSIONS = %w[0.2 0.1].freeze
|
|
29
|
+
|
|
30
|
+
# The prefix §7 reserves for a person, and the whole of what §5.3's tier
|
|
31
|
+
# classifier keys off — which is why §7 makes producers MUST use it for
|
|
32
|
+
# hand-authored or human-confirmed content.
|
|
33
|
+
HUMAN_ACTOR = "human:"
|
|
34
|
+
|
|
35
|
+
# §5.4. The three values the spec names. A producer MAY use another (§4.1),
|
|
36
|
+
# and consumers MUST tolerate it, so this is what the validator warns
|
|
37
|
+
# against — never what a reader rejects.
|
|
38
|
+
STATUSES = %w[draft stable deprecated].freeze
|
|
39
|
+
|
|
40
|
+
# §5.4: "Absent `status` ⇒ `stable`."
|
|
41
|
+
DEFAULT_STATUS = "stable"
|
|
42
|
+
|
|
43
|
+
# §5.5's date spelling, strict. Both ends of the `today >= stale_after`
|
|
44
|
+
# comparison read it: #stale_after_date below, and the clock the linter is
|
|
45
|
+
# handed. Date.iso8601 alone also parses the basic (20260101) and week
|
|
46
|
+
# (2026-W01-1) forms, which would put the two ends on different grammars.
|
|
47
|
+
ISO_DATE = /\A\d{4}-\d{2}-\d{2}\z/.freeze
|
|
48
|
+
|
|
49
|
+
# The grammar for a *cutoff* a reader supplies (`--stale-after`, the MCP
|
|
50
|
+
# `stale_after`). Wider than ISO_DATE on purpose: a cutoff is a moment
|
|
51
|
+
# rather than a calendar day, and the value a caller has to hand is a
|
|
52
|
+
# concept's own `generated.at` — a full timestamp, which Date.iso8601
|
|
53
|
+
# reduces to its date. Narrow enough to still refuse the basic (20260101)
|
|
54
|
+
# and week (2026-W01-1) spellings, which are the ones a reader never means
|
|
55
|
+
# and the parser would silently reinterpret.
|
|
56
|
+
# `T` only: Date.iso8601 raises on the space-separated form, so admitting
|
|
57
|
+
# it here described a grammar one branch wider than the parser behind it —
|
|
58
|
+
# a value that matched the rule and was refused anyway.
|
|
59
|
+
ISO_CUTOFF = /\A\d{4}-\d{2}-\d{2}(?:T.+)?\z/.freeze
|
|
60
|
+
|
|
61
|
+
# §10.1. The type that carries a sanctioned computation.
|
|
62
|
+
ATTESTED_COMPUTATION = "Attested Computation"
|
|
63
|
+
|
|
64
|
+
# The narrowing semantics every surface shares — the CLI's --status/--trust
|
|
65
|
+
# and the MCP shell's filters both fold through here, so a tweak to either
|
|
66
|
+
# rule cannot land on one surface and not the other (which is exactly how
|
|
67
|
+
# `--status stable` and the MCP catalog once answered opposite things
|
|
68
|
+
# about one bundle).
|
|
69
|
+
#
|
|
70
|
+
# `--status` matches the EFFECTIVE status: absent (or blank) reads stable
|
|
71
|
+
# per §5.4, and the value folds through the same serialization #status
|
|
72
|
+
# keeps, so a YAML-boolean `status: no` is "false" everywhere.
|
|
73
|
+
def self.effective_status(value)
|
|
74
|
+
text = fold_status(value)
|
|
75
|
+
text.empty? ? DEFAULT_STATUS : text
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The case-fold *without* §5.4's default — what a filter's argument gets.
|
|
79
|
+
# The default belongs to a concept that declared no status; a caller who
|
|
80
|
+
# asked for one and supplied "" asked for a status no concept has, and
|
|
81
|
+
# answering `stable` there made the CLI's one empty filter that matches
|
|
82
|
+
# something (`--tag ""` and `--trust ""` both match nothing).
|
|
83
|
+
def self.fold_status(value)
|
|
84
|
+
value.nil? ? "" : value.to_s.strip.downcase
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A tier prints hyphenated (`machine-confirmed`); a caller may echo that
|
|
88
|
+
# back or type the underscore form — both fold to the wire spelling.
|
|
89
|
+
def self.fold_tier(value)
|
|
90
|
+
value.to_s.downcase.tr("_", "-")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Whether a surface should *claim* a tier — the display half of §5.3, and
|
|
94
|
+
# deliberately not the same question as "what is the tier".
|
|
95
|
+
#
|
|
96
|
+
# §5.3 derives `unverified` for every concept that declares no verification,
|
|
97
|
+
# which is every concept of every v0.1 bundle. Displaying that unconditionally
|
|
98
|
+
# would paint a provenance verdict onto documents that never made one — the
|
|
99
|
+
# false claim the trust system exists to prevent — so the tier is computed for
|
|
100
|
+
# filtering and withheld from display. A concept that declared `generated` has
|
|
101
|
+
# opted into §5, and its `unverified` is a real answer worth showing; one that
|
|
102
|
+
# declared nothing is silent, and so is every surface reading this.
|
|
103
|
+
#
|
|
104
|
+
# Takes the two wire values so one rule serves both shapes: a Concept asks
|
|
105
|
+
# through #shows_trust?, a catalog row through Bundle::RowFilter.shows_trust?.
|
|
106
|
+
# Shared rather than re-spelled because the gate, the counts and the narrowing
|
|
107
|
+
# have to agree — a gate disagreeing with the counts beside it reads
|
|
108
|
+
# "unverified 3" over two chipped cards.
|
|
109
|
+
def self.shows_trust?(tier, declared_generated)
|
|
110
|
+
folded = fold_tier(tier)
|
|
111
|
+
# A blank tier is nothing to claim. #trust never returns one, so this is
|
|
112
|
+
# the client-side twin's `!!(c.trust && …)` guard kept in step rather than
|
|
113
|
+
# a case the Ruby can reach on its own — and the two are asserted equal.
|
|
114
|
+
return false if folded.empty?
|
|
115
|
+
|
|
116
|
+
!(folded == "unverified" && !declared_generated)
|
|
117
|
+
end
|
|
118
|
+
|
|
17
119
|
# Whether a bundle-relative path names a reserved file rather than a concept.
|
|
18
120
|
# `::File` is explicit: OKF::Concept::File (the on-disk handle) shadows Ruby's
|
|
19
121
|
# File inside this namespace.
|
|
@@ -61,6 +163,8 @@ module OKF
|
|
|
61
163
|
frontmatter["tags"]
|
|
62
164
|
end
|
|
63
165
|
|
|
166
|
+
# The raw v0.1 field, kept readable because §13.1 keeps it consumable; what
|
|
167
|
+
# it *means* is #generated's business.
|
|
64
168
|
def timestamp
|
|
65
169
|
frontmatter["timestamp"]
|
|
66
170
|
end
|
|
@@ -69,21 +173,230 @@ module OKF
|
|
|
69
173
|
self.class.reserved?(path)
|
|
70
174
|
end
|
|
71
175
|
|
|
176
|
+
# ── §5.2 trust: generated, with §13.1's timestamp fallback ──
|
|
177
|
+
|
|
178
|
+
# How the current content was produced, as { "by", "at" }. A lifted
|
|
179
|
+
# `timestamp` yields no `by`: the v0.1 field never recorded an actor, and
|
|
180
|
+
# inventing one — the running user, the gem — is exactly the false
|
|
181
|
+
# provenance claim §5 exists to prevent. A non-mapping `generated` is
|
|
182
|
+
# ignored rather than rejected (§11) and falls back like an absent one.
|
|
183
|
+
def generated
|
|
184
|
+
# Memoized like #sources, on the same premise — the model is immutable
|
|
185
|
+
# once built — and for the same reason: one catalog row asks four times
|
|
186
|
+
# over (#generated_at and #generated_by each read this twice), and every
|
|
187
|
+
# call re-stringifies and re-allocates. `defined?` rather than `||=`
|
|
188
|
+
# because nil is the answer for a whole bundle mid-migration, and `||=`
|
|
189
|
+
# would recompute exactly there.
|
|
190
|
+
return @generated if defined?(@generated)
|
|
191
|
+
|
|
192
|
+
@generated = compute_generated
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The content's last meaningful change (ISO 8601). The fallback is per-key,
|
|
196
|
+
# not per-mapping: a half-migrated document carrying `generated: { by: … }`
|
|
197
|
+
# *plus* a legacy `timestamp` must not lose its date.
|
|
198
|
+
def generated_at
|
|
199
|
+
at = generated && generated["at"]
|
|
200
|
+
return at unless OKF.blank?(at)
|
|
201
|
+
|
|
202
|
+
timestamp unless OKF.blank?(timestamp)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def generated_by
|
|
206
|
+
generated && generated["by"]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Whether the document *declares* a `generated` mapping — raw-key detection,
|
|
210
|
+
# never the fallback. The one predicate that distinguishes hand-written
|
|
211
|
+
# (no provenance at all) from v0.1-with-timestamp, which #generated_at
|
|
212
|
+
# alone conflates.
|
|
213
|
+
def declared_generated?
|
|
214
|
+
frontmatter.key?("generated")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# §5.2: "A single verifier MAY be written as one { by, at } mapping without
|
|
218
|
+
# the list dash. Consumers MUST treat a bare mapping as a one-element list."
|
|
219
|
+
# Entries that are not mappings are dropped here and warned about by the
|
|
220
|
+
# validator; `verified: []` and all-entries-dropped fold into the key-absent
|
|
221
|
+
# case — every degenerate shape reads as unverified.
|
|
222
|
+
def verified
|
|
223
|
+
@verified ||= compute_verified
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# §5.3 — derived, never stored. A stored tier would be subjective,
|
|
227
|
+
# unportable between consumers, and stale the moment a verification lands,
|
|
228
|
+
# so the spec has consumers infer it and OKF record only the events.
|
|
229
|
+
def trust_tier
|
|
230
|
+
events = verified
|
|
231
|
+
return :unverified if events.empty?
|
|
232
|
+
# Stripped, because the linter strips before matching §7's forms: an
|
|
233
|
+
# unstripped compare here let one report call a padded ` human:…` actor
|
|
234
|
+
# human-reviewed in its message and machine-confirmed in its stat.
|
|
235
|
+
return :human_reviewed if events.any? { |event| event["by"].to_s.strip.start_with?(HUMAN_ACTOR) }
|
|
236
|
+
|
|
237
|
+
:machine_confirmed
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The wire spelling of #trust_tier — the hyphenated string every surface
|
|
241
|
+
# prints (rows, lint's trust stat, the page), pinned so a consumer comparing
|
|
242
|
+
# against a literal knows which form arrives.
|
|
243
|
+
def trust
|
|
244
|
+
trust_tier.to_s.tr("_", "-")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Whether this concept's tier is one a surface should show — see
|
|
248
|
+
# .shows_trust? for why the display question is separate from the derivation.
|
|
249
|
+
def shows_trust?
|
|
250
|
+
Concept.shows_trust?(trust, declared_generated?)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ── §5.1 provenance: sources, with §13.1's Citations fallback ──
|
|
254
|
+
|
|
255
|
+
# The materials this concept derives from, as a list of mappings each
|
|
256
|
+
# carrying at least a `resource`. The fallback to a legacy `# Citations`
|
|
257
|
+
# body list fires when the native value yields *zero mappings* — absent,
|
|
258
|
+
# non-list, or a list with no mapping entries — not merely when the key is
|
|
259
|
+
# absent: `sources: [prod-db, warehouse]` has always been a legal free-form
|
|
260
|
+
# key (§4.1), and it must not silently mask a document's real provenance.
|
|
261
|
+
def sources
|
|
262
|
+
# Memoized like the bundle's graph and for the same reason: the model is
|
|
263
|
+
# immutable once built, several lint checks and the row builder each ask,
|
|
264
|
+
# and the v0.1 fallback re-parses the body on every call.
|
|
265
|
+
@sources ||= compute_sources
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# §5.1. Written once as a sibling of `sources`, framing every `usage_count`
|
|
269
|
+
# with a { from, to } range; an entry MAY override it (validated for shape,
|
|
270
|
+
# deliberately consumed by nothing — see model/concept.md).
|
|
271
|
+
def usage_window
|
|
272
|
+
mapping("usage_window")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ── §5.4/§5.5 lifecycle ──
|
|
276
|
+
|
|
277
|
+
# The effective status, defaulted per §5.4. #declared_status keeps the raw
|
|
278
|
+
# value for the surfaces that must not fabricate frontmatter a concept
|
|
279
|
+
# never declared. Defaulted off the same serialization the row prints —
|
|
280
|
+
# not OKF.blank? — because Psych reads `status: no` as false, blank? folds
|
|
281
|
+
# false into "absent", and the row's `&.to_s` prints "false": one concept,
|
|
282
|
+
# two answers. Serializing first keeps every surface on the same string.
|
|
283
|
+
def status
|
|
284
|
+
# Defaulted, *not* folded — and the split is the point. Every surface
|
|
285
|
+
# that displays a status prints what the producer wrote: the catalog row
|
|
286
|
+
# (`declared_status&.to_s`), the card chip, the inspector line. Only
|
|
287
|
+
# comparison folds, which is `.effective_status`'s job and why it is a
|
|
288
|
+
# separate method. Folding here made the library accessor the one place
|
|
289
|
+
# answering `deprecated` where the whole CLI and page say `Deprecated`.
|
|
290
|
+
text = declared_status.nil? ? "" : declared_status.to_s.strip
|
|
291
|
+
text.empty? ? DEFAULT_STATUS : text
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def declared_status
|
|
295
|
+
frontmatter["status"]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# §5.5. An absolute date, not a relative TTL — which is what keeps staleness
|
|
299
|
+
# a plain date comparison with no reference to when the concept was read.
|
|
300
|
+
def stale_after
|
|
301
|
+
frontmatter["stale_after"]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The parsed `stale_after`, or nil when absent or unparseable — strict
|
|
305
|
+
# YYYY-MM-DD, the one spelling §5.5 names. Psych may already have yielded a
|
|
306
|
+
# Date; an unparseable string is a validator warning, never a read failure.
|
|
307
|
+
def stale_after_date
|
|
308
|
+
value = stale_after
|
|
309
|
+
# DateTime < Date, so a bare Date check would admit the one temporal
|
|
310
|
+
# class the strict-YYYY-MM-DD contract excludes — the same exclusion the
|
|
311
|
+
# validator's date check makes, kept in step so lint, validate and the
|
|
312
|
+
# page cannot answer three ways about one value.
|
|
313
|
+
return value if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
314
|
+
|
|
315
|
+
text = value.to_s.strip
|
|
316
|
+
return nil unless text.match?(ISO_DATE)
|
|
317
|
+
|
|
318
|
+
begin
|
|
319
|
+
Date.iso8601(text)
|
|
320
|
+
rescue ArgumentError
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Pure: it takes the day rather than reading the clock. §5.5 puts the
|
|
326
|
+
# boundary *on* the day itself: stale when `today >= stale_after`.
|
|
327
|
+
def stale_on?(today)
|
|
328
|
+
date = stale_after_date
|
|
329
|
+
!date.nil? && today >= date
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# ── §10 attested computation ──
|
|
333
|
+
|
|
334
|
+
def attested_computation?
|
|
335
|
+
type.to_s.strip == ATTESTED_COMPUTATION
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# §10.2. REQUIRED for the type — but §11's conformance conditions are only
|
|
339
|
+
# three, so its absence is a warning and never an error.
|
|
340
|
+
def runtime
|
|
341
|
+
frontmatter["runtime"]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# The typed, named holes an agent may fill (§10.3: bind values for declared
|
|
345
|
+
# parameters only; never author or edit the computation).
|
|
346
|
+
def parameters
|
|
347
|
+
Array(frontmatter["parameters"]).grep(Hash)
|
|
348
|
+
.map { |entry| Markdown::Frontmatter.stringify_keys(entry) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# §10.3. A path to a file holding the computation, used instead of an inline
|
|
352
|
+
# body fence. Absent ⇒ the `# Computation` fence is the computation.
|
|
353
|
+
def computation
|
|
354
|
+
frontmatter["computation"]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# §10.2. How the computation is run: `resource` names run instructions,
|
|
358
|
+
# `receipt` declares the fields a run must return.
|
|
359
|
+
def executor
|
|
360
|
+
mapping("executor")
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# §10.2. The deterministic (no-LLM) check that takes a receipt and returns a
|
|
364
|
+
# verdict. Meant to run consumer-side.
|
|
365
|
+
def attester
|
|
366
|
+
mapping("attester")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The §13.1 lifted entries, parsed once for however many readers ask —
|
|
370
|
+
# #sources' fallback and the linter's broken_source (which checks the
|
|
371
|
+
# section's targets even beside a native list) share this parse.
|
|
372
|
+
def citation_entries
|
|
373
|
+
@citation_entries ||= Markdown::Citations.entries(body)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# ── detection (lint's and the surfaces'; never reading's) ──
|
|
377
|
+
|
|
378
|
+
def legacy_timestamp?
|
|
379
|
+
frontmatter.key?("timestamp")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Memoized with defined? because the answer may be false: the linter asks
|
|
383
|
+
# per check, and each un-memoized ask was a full body scan.
|
|
384
|
+
def legacy_citations?
|
|
385
|
+
return @legacy_citations if defined?(@legacy_citations)
|
|
386
|
+
|
|
387
|
+
@legacy_citations = !Markdown::Citations.section(body).nil?
|
|
388
|
+
end
|
|
389
|
+
|
|
72
390
|
# ── analysis (pure; the same primitives the graph/linter use) ──
|
|
73
391
|
|
|
74
|
-
# Raw markdown cross-link targets in the body, in document order (spec §
|
|
392
|
+
# Raw markdown cross-link targets in the body, in document order (spec §6.1).
|
|
75
393
|
def links
|
|
76
394
|
Markdown::Links.extract(body)
|
|
77
395
|
end
|
|
78
396
|
|
|
79
|
-
# Citation link targets under the `# Citations` section (spec §8), or [].
|
|
80
|
-
def citations
|
|
81
|
-
Markdown::Citations.targets(body)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
397
|
# Body links that point outside the bundle — external URLs and mailto:.
|
|
85
398
|
def external_links
|
|
86
|
-
links.select { |raw| raw.match?(Markdown::Links::SCHEME) || raw.
|
|
399
|
+
links.select { |raw| raw.match?(Markdown::Links::SCHEME) || raw.match?(Markdown::Links::MAILTO) }
|
|
87
400
|
end
|
|
88
401
|
|
|
89
402
|
# Serialize back to a markdown document (frontmatter + body) — the inverse of
|
|
@@ -97,5 +410,44 @@ module OKF
|
|
|
97
410
|
def lint(**options)
|
|
98
411
|
Bundle.new(concepts: [ self ]).lint(only: CONCEPT_SCOPED_CHECKS, **options)
|
|
99
412
|
end
|
|
413
|
+
|
|
414
|
+
private
|
|
415
|
+
|
|
416
|
+
def compute_generated
|
|
417
|
+
native = frontmatter["generated"]
|
|
418
|
+
return Markdown::Frontmatter.stringify_keys(native) if native.is_a?(Hash)
|
|
419
|
+
return nil if OKF.blank?(timestamp)
|
|
420
|
+
|
|
421
|
+
{ "at" => timestamp }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def compute_verified
|
|
425
|
+
raw = frontmatter["verified"]
|
|
426
|
+
entries = raw.is_a?(Hash) ? [ raw ] : Array(raw)
|
|
427
|
+
entries.grep(Hash).map { |entry| Markdown::Frontmatter.stringify_keys(entry) }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def compute_sources
|
|
431
|
+
native = frontmatter["sources"]
|
|
432
|
+
if native.is_a?(Array)
|
|
433
|
+
entries = native.grep(Hash)
|
|
434
|
+
return entries.map { |entry| Markdown::Frontmatter.stringify_keys(entry) } unless entries.empty?
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
citation_entries.map do |entry|
|
|
438
|
+
source = {}
|
|
439
|
+
source["title"] = entry[:text] unless OKF.blank?(entry[:text])
|
|
440
|
+
source["resource"] = entry[:target]
|
|
441
|
+
source
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# A frontmatter value that must be a mapping, with its keys stringified — or
|
|
446
|
+
# nil when it is anything else. The validator warns about the "anything
|
|
447
|
+
# else" case; readers just see nothing, per §11's tolerate-don't-reject.
|
|
448
|
+
def mapping(key)
|
|
449
|
+
value = frontmatter[key]
|
|
450
|
+
value.is_a?(Hash) ? Markdown::Frontmatter.stringify_keys(value) : nil
|
|
451
|
+
end
|
|
100
452
|
end
|
|
101
453
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
module Markdown
|
|
5
|
-
# Parses the
|
|
5
|
+
# Parses the retired `# Citations` section of a v0.1 concept body (§13.1): the
|
|
6
6
|
# block of external sources listed at the bottom of a document. Pure and
|
|
7
7
|
# fence-aware, mirroring Links; it reuses Links.extract to pull the citation link
|
|
8
8
|
# targets so citations and cross-links agree on what counts as a link.
|
|
@@ -10,6 +10,13 @@ module OKF
|
|
|
10
10
|
# A markdown ATX heading line: 1–6 `#`, whitespace, then the heading text.
|
|
11
11
|
HEADING = /\A(\#{1,6})\s+(.*?)\s*\z/.freeze
|
|
12
12
|
CITATIONS = /\ACitations\z/i.freeze
|
|
13
|
+
# A list item (or lone line) that is only a URL — the v0.1 spelling the
|
|
14
|
+
# v0.2 SPEC's own Appendix A uses — and the same item written as an
|
|
15
|
+
# autolink (which also admits mailto:). Both are citations with no text
|
|
16
|
+
# to lift into a title; both compose their scheme from Links' one
|
|
17
|
+
# grammar, so citations and cross-links answer the case question alike.
|
|
18
|
+
URL_ITEM = %r{\A(?:[-*+]\s+)?(#{Links::SCHEME_NAME}://\S+)\z}.freeze
|
|
19
|
+
AUTOLINK_ITEM = /\A(?:[-*+]\s+)?<(#{Links::SCHEME_NAME}:[^>\s]+)>\z/.freeze
|
|
13
20
|
|
|
14
21
|
module_function
|
|
15
22
|
|
|
@@ -40,9 +47,39 @@ module OKF
|
|
|
40
47
|
lines.join unless level.nil?
|
|
41
48
|
end
|
|
42
49
|
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
# The citation entries as { text:, target: } pairs, in document order —
|
|
51
|
+
# what Concept#sources lifts into { "title", "resource" } mappings. Three
|
|
52
|
+
# item forms (§13.1): labelled links
|
|
53
|
+
# carry their text; bare-URL and autolink items have none; a
|
|
54
|
+
# reference-style citation still yields its target through Links.extract.
|
|
55
|
+
def entries(body)
|
|
56
|
+
text = section(body).to_s
|
|
57
|
+
definitions = Links.reference_definitions(text)
|
|
58
|
+
found = []
|
|
59
|
+
Links.each_prose_line(text) do |line|
|
|
60
|
+
item = line.strip
|
|
61
|
+
match = AUTOLINK_ITEM.match(item) || URL_ITEM.match(item)
|
|
62
|
+
if match
|
|
63
|
+
found << { text: "", target: match[1] }
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Both grammars scan the same line and merge by offset, so the list
|
|
68
|
+
# keeps document order *within* a line too — scanning all inline
|
|
69
|
+
# links before any reference links reversed a mixed line's own order,
|
|
70
|
+
# and a migration lifting sources off this output writes the list
|
|
71
|
+
# permanently. Reference items resolve in place, text kept.
|
|
72
|
+
items = []
|
|
73
|
+
line.scan(Links::INLINE_LINK) do |label, target|
|
|
74
|
+
items << [ Regexp.last_match.begin(0), { text: label.to_s.strip, target: target } ]
|
|
75
|
+
end
|
|
76
|
+
line.scan(Links::REFERENCE_LINK) do |label, explicit|
|
|
77
|
+
target = definitions[(explicit.empty? ? label : explicit).strip.downcase]
|
|
78
|
+
items << [ Regexp.last_match.begin(0), { text: label.to_s.strip, target: target } ] if target
|
|
79
|
+
end
|
|
80
|
+
items.sort_by(&:first).each { |_, entry| found << entry }
|
|
81
|
+
end
|
|
82
|
+
found
|
|
46
83
|
end
|
|
47
84
|
end
|
|
48
85
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
# Primitives for parsing structure out of a markdown document — the format layer
|
|
5
|
-
# (§4 frontmatter, §
|
|
5
|
+
# (§4 frontmatter, §6 links, §5.1 sources) shared by Concept, Bundle, and the
|
|
6
6
|
# analyzers. Pure string-in/string-out; no disk, no domain knowledge.
|
|
7
7
|
module Markdown
|
|
8
8
|
module Frontmatter
|