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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -0
  3. data/README.md +94 -466
  4. data/lib/okf/bundle/folder.rb +48 -3
  5. data/lib/okf/bundle/graph.rb +12 -3
  6. data/lib/okf/bundle/linter.rb +470 -47
  7. data/lib/okf/bundle/reader.rb +47 -18
  8. data/lib/okf/bundle/references.rb +111 -0
  9. data/lib/okf/bundle/row_filter.rb +53 -0
  10. data/lib/okf/bundle/search.rb +20 -2
  11. data/lib/okf/bundle/validator/result.rb +6 -3
  12. data/lib/okf/bundle/validator.rb +267 -26
  13. data/lib/okf/bundle/writer.rb +1 -1
  14. data/lib/okf/bundle.rb +124 -8
  15. data/lib/okf/cli/catalog.rb +2 -2
  16. data/lib/okf/cli/command.rb +93 -19
  17. data/lib/okf/cli/dirs.rb +1 -1
  18. data/lib/okf/cli/files.rb +2 -2
  19. data/lib/okf/cli/index.rb +3 -3
  20. data/lib/okf/cli/lint.rb +70 -12
  21. data/lib/okf/cli/references.rb +97 -0
  22. data/lib/okf/cli/search.rb +23 -8
  23. data/lib/okf/cli/stats.rb +3 -39
  24. data/lib/okf/cli/tags.rb +6 -43
  25. data/lib/okf/cli/types.rb +1 -1
  26. data/lib/okf/cli/validate.rb +3 -3
  27. data/lib/okf/cli.rb +4 -1
  28. data/lib/okf/concept/file.rb +17 -2
  29. data/lib/okf/concept.rb +362 -10
  30. data/lib/okf/markdown/citations.rb +41 -4
  31. data/lib/okf/markdown/frontmatter.rb +1 -1
  32. data/lib/okf/markdown/links.rb +67 -7
  33. data/lib/okf/path.rb +17 -3
  34. data/lib/okf/render/graph/template.html.erb +173 -41
  35. data/lib/okf/render/graph.rb +11 -3
  36. data/lib/okf/safe_read.rb +50 -0
  37. data/lib/okf/server/app.rb +47 -15
  38. data/lib/okf/server/hub.rb +1 -1
  39. data/lib/okf/skill/SKILL.md +14 -12
  40. data/lib/okf/skill/playbooks/curate.md +8 -3
  41. data/lib/okf/skill/playbooks/doctor.md +3 -1
  42. data/lib/okf/skill/playbooks/maintain.md +7 -6
  43. data/lib/okf/skill/playbooks/menu.md +5 -4
  44. data/lib/okf/skill/playbooks/migrate.md +31 -8
  45. data/lib/okf/skill/playbooks/produce.md +16 -9
  46. data/lib/okf/skill/playbooks/search.md +2 -2
  47. data/lib/okf/skill/reference/SPEC.md +739 -187
  48. data/lib/okf/skill/reference/authoring.md +154 -35
  49. data/lib/okf/skill/reference/cli.md +160 -44
  50. data/lib/okf/skill/templates/attested-computation.md +41 -0
  51. data/lib/okf/skill/templates/concept.md +13 -6
  52. data/lib/okf/skill/templates/root-index.md +1 -1
  53. data/lib/okf/version.rb +1 -1
  54. data/lib/okf.rb +23 -2
  55. metadata +7 -3
  56. data/CODE_OF_CONDUCT.md +0 -10
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OKF
4
4
  class CLI
5
- # The §9 conformance judge: is this legal OKF? Binary and tolerant — it is
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 v0.1 conformance" ]
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 v0.1 conformance — #{bundle_label(dir)}"
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 timestamp status backlog_ref dir top_dir links_out links_in],
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
 
@@ -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
- content = ::File.read(absolute_path, encoding: "UTF-8")
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 bundle.
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 missing_timestamp
14
- uncited_external self_link unused_reference_def undefined_reference
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 §5).
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.start_with?("mailto:") }
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 conventional `# Citations` section of a concept body (spec §8): 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
- # Citation link targets within the `# Citations` section (empty when absent).
44
- def targets(body)
45
- Links.extract(section(body).to_s)
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 links, §8 citations) shared by Concept, Bundle, and the
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