okf 1.13.0 → 2.1.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -0
  3. data/README.md +34 -11
  4. data/lib/okf/bundle/folder.rb +27 -1
  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 +10 -4
  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 +105 -4
  15. data/lib/okf/cli/catalog.rb +1 -1
  16. data/lib/okf/cli/command.rb +27 -8
  17. data/lib/okf/cli/files.rb +1 -1
  18. data/lib/okf/cli/index.rb +1 -1
  19. data/lib/okf/cli/lint.rb +70 -12
  20. data/lib/okf/cli/references.rb +97 -0
  21. data/lib/okf/cli/search.rb +2 -1
  22. data/lib/okf/cli/stats.rb +3 -39
  23. data/lib/okf/cli/tags.rb +6 -43
  24. data/lib/okf/cli/types.rb +1 -1
  25. data/lib/okf/cli/validate.rb +3 -3
  26. data/lib/okf/cli.rb +24 -3
  27. data/lib/okf/concept.rb +362 -10
  28. data/lib/okf/markdown/citations.rb +41 -4
  29. data/lib/okf/markdown/frontmatter.rb +1 -1
  30. data/lib/okf/markdown/links.rb +67 -7
  31. data/lib/okf/render/graph/template.html.erb +173 -41
  32. data/lib/okf/render/graph.rb +11 -3
  33. data/lib/okf/server/app.rb +47 -15
  34. data/lib/okf/server/hub.rb +1 -1
  35. data/lib/okf/skill/SKILL.md +28 -18
  36. data/lib/okf/skill/playbooks/curate.md +8 -3
  37. data/lib/okf/skill/playbooks/doctor.md +3 -1
  38. data/lib/okf/skill/playbooks/maintain.md +10 -7
  39. data/lib/okf/skill/playbooks/menu.md +5 -4
  40. data/lib/okf/skill/playbooks/migrate.md +31 -8
  41. data/lib/okf/skill/playbooks/produce.md +20 -11
  42. data/lib/okf/skill/playbooks/search.md +2 -2
  43. data/lib/okf/skill/reference/SPEC.md +739 -187
  44. data/lib/okf/skill/reference/authoring.md +164 -48
  45. data/lib/okf/skill/reference/cli/checks.md +171 -0
  46. data/lib/okf/skill/reference/cli/graph.md +49 -0
  47. data/lib/okf/skill/reference/cli/map.md +98 -0
  48. data/lib/okf/skill/reference/cli/registry.md +70 -0
  49. data/lib/okf/skill/reference/cli/search.md +130 -0
  50. data/lib/okf/skill/reference/cli/serve.md +83 -0
  51. data/lib/okf/skill/reference/cli/views.md +59 -0
  52. data/lib/okf/skill/reference/cli.md +44 -501
  53. data/lib/okf/skill/reference/spec-map.md +32 -0
  54. data/lib/okf/skill/templates/attested-computation.md +41 -0
  55. data/lib/okf/skill/templates/concept.md +13 -6
  56. data/lib/okf/skill/templates/root-index.md +1 -1
  57. data/lib/okf/version.rb +1 -1
  58. data/lib/okf.rb +22 -2
  59. metadata +13 -1
@@ -4,25 +4,50 @@ module OKF
4
4
  class Bundle
5
5
  # Lints a bundle for curation quality — the deterministic subset of the
6
6
  # ingest → query → lint loop (overview.md): reachability, backlog, completeness,
7
- # freshness, provenance, and hygiene. Pure — it reads nothing from disk and works
8
- # entirely on the in-memory OKF::Bundle, mirroring OKF::Bundle::Validator.
7
+ # freshness, provenance, attestation, migration, and hygiene. Pure — it reads
8
+ # nothing from disk and works entirely on the in-memory OKF::Bundle, mirroring
9
+ # OKF::Bundle::Validator.
9
10
  #
10
- # Unlike OKF::Bundle::Validator (the §9 conformance gate, which MUST NOT reject for broken
11
- # links or missing optional fields), lint never rejects a bundle: it reports
11
+ # Unlike OKF::Bundle::Validator (the §11 conformance gate, which MUST NOT reject for
12
+ # broken links or missing optional fields), lint never rejects a bundle: it reports
12
13
  # `:warn` and `:info` findings the spec marks as tolerable, and emits them as
13
14
  # structured data (OKF::Bundle::Linter::Report) for a human or agent to act on. Contradictions
14
15
  # and semantic staleness are NOT detected here — they need meaning, not structure;
15
16
  # the JSON report is the substrate an agent consumes for those passes.
16
17
  class Linter
17
- # All checks, in display/registry order. `--only`/`--except` select from these.
18
- CHECKS = %i[
19
- orphan not_in_index disconnected_component unlinked
20
- missing_concept broken_index_entry
21
- stub missing_title missing_description missing_timestamp
22
- stale
23
- uncited_external broken_citation
24
- duplicate_title unused_reference_def undefined_reference self_link
25
- ].freeze
18
+ # Severity is API: machine consumers gate edits and CI on `:warn` and drop
19
+ # `:info`, so an id changing level changes its behavior for them — this map
20
+ # is pinned by a test, and a new gateable state gets a flag (`--fail-on
21
+ # info`), never a severity promotion. Two calls worth their one sentence:
22
+ # `unattributed_claim` warns while its join-twin `unused_source` informs,
23
+ # because a dangling footnote misattributes a claim — a correctness defect —
24
+ # while an uncited source is only slack. And `expired` informs rather than
25
+ # warns: a `stale_after` passes on the calendar, not on a change, so a warn
26
+ # would fail a `--fail-on warn` gate on a morning nobody chose.
27
+ SEVERITIES = {
28
+ orphan: :warn, not_in_index: :warn, disconnected_component: :info, unlinked: :info,
29
+ missing_concept: :info, broken_index_entry: :warn,
30
+ stub: :info, missing_title: :info, missing_description: :info, missing_generated: :info,
31
+ expired: :info, stale: :warn,
32
+ uncited_external: :info, broken_source: :warn, unattributed_claim: :warn,
33
+ unused_source: :info, unprefixed_actor: :info,
34
+ incomplete_computation: :warn, broken_attestation_ref: :warn,
35
+ legacy_timestamp: :info, legacy_citations: :info,
36
+ duplicate_title: :info, unused_reference_def: :info, undefined_reference: :warn, self_link: :info,
37
+ log_order: :info
38
+ }.freeze
39
+
40
+ # All checks, in display/registry order — derived from the severity map
41
+ # (insertion-ordered) rather than hand-listed twice: two parallel lists
42
+ # of the same 25 ids needed a test just to police their sync.
43
+ # `--only`/`--except` select from these.
44
+ CHECKS = SEVERITIES.keys.freeze
45
+
46
+ # An ATX heading naming the §10.3 computation section.
47
+ COMPUTATION_HEADING = /\A\#{1,6}\s+Computation\s*\z/i.freeze
48
+
49
+ # §7's three actor forms: `<producer>/<version>`, `human:<id>`, `process:<id>`.
50
+ ACTOR_FORMS = [ %r{\A\S+/\S+\z}, /\Ahuman:\S+\z/, /\Aprocess:\S+\z/ ].freeze
26
51
 
27
52
  DEFAULT_MIN_BODY = 50
28
53
  HUB_LIMIT = 5
@@ -31,10 +56,17 @@ module OKF
31
56
  new(bundle, **options).call
32
57
  end
33
58
 
34
- def initialize(bundle, min_body: DEFAULT_MIN_BODY, stale_before: nil, only: nil, except: nil)
59
+ # `today` is injected for the same reason `stale_before` is: the linter is
60
+ # pure and never reads the clock. Without it `expired` cannot know whether
61
+ # a `stale_after` has passed and does not run — and confesses, in
62
+ # stats[:skipped_checks], because a gate that is sometimes absent and does
63
+ # not say so converts "unchecked" into "checked and fine". The CLI always
64
+ # passes today; a library caller that wants the check passes `today:`.
65
+ def initialize(bundle, min_body: DEFAULT_MIN_BODY, stale_before: nil, today: nil, only: nil, except: nil)
35
66
  @bundle = bundle
36
67
  @min_body = min_body
37
68
  @stale_before = stale_before
69
+ @today = coerce_today(today)
38
70
  @only = only
39
71
  @except = except
40
72
  @report = Report.new
@@ -60,20 +92,65 @@ module OKF
60
92
  @indexed_ids = indexed_by_dir.values.reduce(Set.new, :|)
61
93
  end
62
94
 
95
+ # An id outside CHECKS refuses by name rather than intersecting to
96
+ # nothing: a caller pinned to a renamed id would otherwise get an empty,
97
+ # healthy report with skipped_checks: [] — "checked and fine" over a run
98
+ # that ran nothing, the exact silence skipped_checks exists to prevent.
99
+ # The CLI and the MCP shell validate first and exit 2; this is the same
100
+ # refusal for the library caller who has no argv layer in front.
63
101
  def selected_checks
102
+ unknown = (Array(@only) + Array(@except)).map(&:to_sym) - CHECKS
103
+ raise ArgumentError, "unknown check(s): #{unknown.uniq.join(", ")} (checks: #{CHECKS.join(", ")})" unless unknown.empty?
104
+
64
105
  checks = CHECKS
65
106
  checks &= Array(@only).map(&:to_sym) if @only
66
107
  checks -= Array(@except).map(&:to_sym) if @except
67
108
  checks
68
109
  end
69
110
 
111
+ # The documented contract is a Date, but the comment above *invites*
112
+ # library callers to pass one — so a Time or an ISO string coerces
113
+ # instead of detonating inside Concept#stale_on? halfway through a run,
114
+ # and anything else fails fast, here, with the contract named.
115
+ def coerce_today(value)
116
+ case value
117
+ when nil then nil
118
+ when DateTime, Time then value.to_date
119
+ when Date then value
120
+ when String then parse_today(value)
121
+ else raise ArgumentError, "today: must be a Date (got #{value.class})"
122
+ end
123
+ end
124
+
125
+ # The rescue belongs to the parse alone. Wrapping the whole `case` put it
126
+ # around its own `else` too, so the refusal that names the class was
127
+ # caught and rewritten into the string message on its way out — a branch
128
+ # that could not be reached from any caller.
129
+ def parse_today(value)
130
+ raise ArgumentError, "not YYYY-MM-DD" unless value.match?(Concept::ISO_DATE)
131
+
132
+ Date.iso8601(value)
133
+ rescue ArgumentError
134
+ raise ArgumentError, "today: must be a Date or a YYYY-MM-DD string (got #{value.inspect})"
135
+ end
136
+
137
+ # Every finding lands through here, so a check's severity has exactly one
138
+ # home — the SEVERITIES map — and cannot drift per call site.
139
+ def add(check, path, message, metric: nil)
140
+ if SEVERITIES.fetch(check) == :warn
141
+ @report.add_warning(check, path, message, metric: metric)
142
+ else
143
+ @report.add_info(check, path, message, metric: metric)
144
+ end
145
+ end
146
+
70
147
  # ── Reachability ─────────────────────────────────────────────────────────
71
148
 
72
149
  def check_orphan
73
150
  @concepts.each do |concept|
74
151
  next if @inbound[concept.id].positive? || @indexed_ids.include?(concept.id)
75
152
 
76
- @report.add_warning(:orphan, "#{concept.id}.md",
153
+ add(:orphan, "#{concept.id}.md",
77
154
  "unreachable: no inbound links and not listed in any index.md")
78
155
  end
79
156
  end
@@ -83,7 +160,7 @@ module OKF
83
160
  concepts_in(dir).each do |concept|
84
161
  next if listed.include?(concept.id)
85
162
 
86
- @report.add_warning(:not_in_index, "#{concept.id}.md",
163
+ add(:not_in_index, "#{concept.id}.md",
87
164
  "not listed in its directory index (#{index_path_for(dir)})",
88
165
  metric: { index: index_path_for(dir) })
89
166
  end
@@ -101,7 +178,7 @@ module OKF
101
178
  groups.each do |members|
102
179
  next if members.equal?(main) || members.size < 2
103
180
 
104
- @report.add_info(:disconnected_component, nil,
181
+ add(:disconnected_component, nil,
105
182
  "#{members.size} concepts form an island disconnected from the main graph",
106
183
  metric: { size: members.size, members: members.sort })
107
184
  end
@@ -118,7 +195,7 @@ module OKF
118
195
  @concepts.each do |concept|
119
196
  next unless loose.include?(concept.id)
120
197
 
121
- @report.add_info(:unlinked, "#{concept.id}.md",
198
+ add(:unlinked, "#{concept.id}.md",
122
199
  "no cross-links (in or out); it floats in the graph")
123
200
  end
124
201
  end
@@ -142,7 +219,7 @@ module OKF
142
219
  end
143
220
 
144
221
  demand.sort_by { |target, entry| [ -entry[:references], target ] }.each do |target, entry|
145
- @report.add_info(:missing_concept, target,
222
+ add(:missing_concept, target,
146
223
  "referenced by #{entry[:references]} link(s) across #{entry[:sources].size} concept(s) but does not exist",
147
224
  metric: { references: entry[:references], sources: entry[:sources] })
148
225
  end
@@ -154,7 +231,7 @@ module OKF
154
231
  target = Markdown::Links.resolve(raw, from: path, bundle: @bundle.root)
155
232
  next if target.nil? || @existing.include?(target)
156
233
 
157
- @report.add_warning(:broken_index_entry, path,
234
+ add(:broken_index_entry, path,
158
235
  "index links to missing concept `#{raw}`", metric: { target: target })
159
236
  end
160
237
  end
@@ -167,7 +244,7 @@ module OKF
167
244
  length = concept.body.to_s.strip.length
168
245
  next if length >= @min_body
169
246
 
170
- @report.add_info(:stub, "#{concept.id}.md",
247
+ add(:stub, "#{concept.id}.md",
171
248
  "body is #{length} character(s) (under min-body #{@min_body})",
172
249
  metric: { chars: length, min: @min_body })
173
250
  end
@@ -181,63 +258,320 @@ module OKF
181
258
  each_missing(:description, :missing_description, "description")
182
259
  end
183
260
 
184
- def check_missing_timestamp
261
+ # Raw keys, not the accessor: a v0.1 `timestamp` is a recorded change time
262
+ # (§13.1 reads it), so the check is quiet on either spelling — it fires
263
+ # only when a document records nothing at all.
264
+ def check_missing_generated
185
265
  @concepts.each do |concept|
186
- next unless concept.timestamp.nil?
266
+ next if concept.declared_generated? || concept.legacy_timestamp?
187
267
 
188
- @report.add_info(:missing_timestamp, "#{concept.id}.md", "missing recommended field: timestamp")
268
+ add(:missing_generated, "#{concept.id}.md", "missing recommended field: generated")
189
269
  end
190
270
  end
191
271
 
192
- # ── Freshness (opt-in) ───────────────────────────────────────────────────────
272
+ # ── Freshness ────────────────────────────────────────────────────────────────
273
+
274
+ # §5.5 — the author's own declared expiry, clock-gated: it runs only when
275
+ # the caller supplies a day to compare against, exactly the opt-in shape
276
+ # :stale has. Deliberately separate from :stale, which is a
277
+ # reader-supplied cutoff over `generated_at`: merging them would make a
278
+ # bundle's own contract depend on a CLI flag.
279
+ def check_expired
280
+ return if @today.nil?
281
+
282
+ @concepts.each do |concept|
283
+ next unless concept.stale_on?(@today)
284
+
285
+ add(:expired, "#{concept.id}.md",
286
+ "expired on #{concept.stale_after_date} (stale_after)",
287
+ metric: { stale_after: concept.stale_after_date.to_s,
288
+ days_past: (@today - concept.stale_after_date).to_i })
289
+ end
290
+ end
193
291
 
292
+ # Opt-in, and the operator's opinion rather than the bundle's: a cutoff
293
+ # supplied at the command line, compared against the last-change time.
194
294
  def check_stale
195
295
  return if @stale_before.nil?
196
296
 
197
297
  @concepts.each do |concept|
198
- at = parse_time(concept.timestamp)
298
+ at = parse_time(concept.generated_at)
199
299
  next if at.nil? || at >= @stale_before
200
300
 
201
- @report.add_warning(:stale, "#{concept.id}.md",
202
- "last updated #{concept.timestamp}; older than cutoff #{@stale_before}",
203
- metric: { timestamp: concept.timestamp.to_s, cutoff: @stale_before.to_s })
301
+ add(:stale, "#{concept.id}.md",
302
+ "last updated #{concept.generated_at}; older than cutoff #{@stale_before}",
303
+ metric: { generated_at: concept.generated_at.to_s, cutoff: @stale_before.to_s })
204
304
  end
205
305
  end
206
306
 
207
- # ── Provenance (§8) ──────────────────────────────────────────────────────────
307
+ # ── Provenance (§5.1/§5.2) ───────────────────────────────────────────────────
208
308
 
309
+ # Asks `#sources` — the one place lint deliberately reads through the
310
+ # fallback-carrying accessor — so a v0.1 `# Citations` silences it and a
311
+ # migrated `sources` block silences it too. The section check beside it
312
+ # is not redundant: a prose-only `# Citations` ("See the Q3 report")
313
+ # yields no lifted mappings, and firing on it would fault a v0.1 concept
314
+ # that did record provenance — just not as links.
209
315
  def check_uncited_external
210
316
  @concepts.each do |concept|
317
+ # Memoized guards first: the link extraction is the expensive pass,
318
+ # and a sourced concept never needs it run.
319
+ next if concept.sources.any? || concept.legacy_citations?
320
+
211
321
  externals = Markdown::Links.extract(concept.body).count { |raw| external?(raw) }
212
- next if externals.zero? || Markdown::Citations.section(concept.body)
322
+ next if externals.zero?
213
323
 
214
- @report.add_info(:uncited_external, "#{concept.id}.md",
215
- "body has external link(s) but no # Citations section",
324
+ add(:uncited_external, "#{concept.id}.md",
325
+ "body has external link(s) but no sources",
216
326
  metric: { external_count: externals })
217
327
  end
218
328
  end
219
329
 
220
- # Verifies .md citation targets only; §8 also permits non-.md references/ assets,
221
- # which the Bundle does not index and so cannot be checked here.
222
- def check_broken_citation
330
+ # The resolver is the discriminator: `Links.resolve` accepts only an
331
+ # in-bundle `.md` path, so URLs, scope descriptors, and non-`.md` assets
332
+ # (`references/attesters/revenue.py`) are exempt by construction — §5.1
333
+ # permits all three, and the Bundle does not index them.
334
+ #
335
+ # A leftover `# Citations` section is checked *beside* the native list,
336
+ # not only through the fallback: on a half-migrated concept the native
337
+ # entries win #sources, but §13.1 keeps the section readable, and its
338
+ # broken target was a warn before migration started — adopting `sources:`
339
+ # must not downgrade that gate to the info-level backlog checks. When the
340
+ # fallback did fire, the lifted entries and the section are the same
341
+ # values, deduplicated below.
342
+ def check_broken_source
223
343
  @concepts.each do |concept|
224
- Markdown::Citations.targets(concept.body).each do |raw|
344
+ resources = concept.sources.map { |source| source["resource"].to_s }
345
+ resources += concept.citation_entries.map { |entry| entry[:target].to_s } if concept.legacy_citations?
346
+ resources.uniq.each do |raw|
225
347
  target = Markdown::Links.resolve(raw, from: concept.path, bundle: @bundle.root)
226
348
  next if target.nil? || @existing.include?(target)
227
349
 
228
- @report.add_warning(:broken_citation, "#{concept.id}.md",
229
- "citation target `#{raw}` does not exist in the bundle", metric: { target: target })
350
+ add(:broken_source, "#{concept.id}.md",
351
+ "source target `#{raw}` does not exist in the bundle", metric: { target: target })
352
+ end
353
+ end
354
+ end
355
+
356
+ # §5.1's keyed attribution, checked in both directions. A dangling footnote
357
+ # misattributes a claim, so it warns; an uncited source (below) is only
358
+ # slack, so it informs. Two boundaries on the join: a concept with no
359
+ # sources[].id at all has not adopted keyed attribution — §5.1 does not
360
+ # reserve footnotes for it, and an ordinary GFM footnote is prose, not a
361
+ # fault — and the label↔id comparison folds case the way GFM resolves
362
+ # footnotes and reference_definitions already folds labels.
363
+ def check_unattributed_claim
364
+ @concepts.each do |concept|
365
+ ids = source_ids(concept).map(&:downcase)
366
+ next if ids.empty?
367
+
368
+ defined = Markdown::Links.footnote_definitions(concept.body).map(&:downcase)
369
+ footnote_labels(concept).each do |label|
370
+ next if ids.include?(label.downcase)
371
+ # A label with its own definition is an ordinary GFM content
372
+ # footnote — it renders complete, and §5.1 never reserves the
373
+ # label space for attribution. The dangling ones (no id, no
374
+ # definition) are the misattributions this check exists for.
375
+ next if defined.include?(label.downcase)
376
+
377
+ add(:unattributed_claim, "#{concept.id}.md",
378
+ "footnote `[^#{label}]` has no matching sources[].id", metric: { label: label })
379
+ end
380
+ end
381
+ end
382
+
383
+ # Sources with no `id` never participate — a lifted v0.1 citation has
384
+ # none, and it would be wrong to fault a bundle for not having adopted
385
+ # keyed attribution. The same case fold, and the same guard-first order,
386
+ # as its join-twin above: without them every concept of a non-adopting
387
+ # bundle paid a full body scan to compare against an empty set.
388
+ def check_unused_source
389
+ @concepts.each do |concept|
390
+ ids = source_ids(concept)
391
+ next if ids.empty?
392
+
393
+ labels = footnote_labels(concept).to_set(&:downcase)
394
+ ids.each do |id|
395
+ next if labels.include?(id.downcase)
396
+
397
+ add(:unused_source, "#{concept.id}.md",
398
+ "source `#{id}` is never cited by a footnote", metric: { id: id })
399
+ end
400
+ end
401
+ end
402
+
403
+ # A missing `generated.by` is deliberately NOT a lint check: §5.2 marks
404
+ # `by` REQUIRED within the mapping, REQUIRED-within is the validator's
405
+ # side of the split, and the validator's :generated_by warning already
406
+ # reports it — a lint twin double-counted one defect with two severities,
407
+ # exactly what the parallel `verified[].by` family never did.
408
+
409
+ # The two fields §7 gives the actor convention to. `verified[].by` is
410
+ # where the misread bites — §5.3 derives the tier from it, so a bare
411
+ # `by: owner` silently reads as machine-confirmed. `generated.by` feeds
412
+ # no tier, but it is the field §7 names first, and a form nothing can
413
+ # classify leaves a provenance reader unable to tell a person from a
414
+ # process — so it earns the finding with its own consequence. Still not
415
+ # `sources[].author` (the SPEC's own examples use `team:<id>` there),
416
+ # and a *missing* `generated.by` stays the validator's warning.
417
+ # Info is load-bearing: it must inform, never block.
418
+ def check_unprefixed_actor
419
+ @concepts.each do |concept|
420
+ fields = []
421
+ generated_by = concept.generated && concept.generated["by"]
422
+ fields << [ "generated.by", generated_by, generated_consequence ]
423
+ concept.verified.each { |event| fields << [ "verified.by", event["by"], nil ] }
424
+ fields.each do |field, raw, consequence|
425
+ actor = raw.to_s.strip
426
+ next if actor.empty? || ACTOR_FORMS.any? { |form| actor.match?(form) }
427
+
428
+ add(:unprefixed_actor, "#{concept.id}.md",
429
+ "#{field} `#{actor}` matches none of §7's forms (`<producer>/<version>`, `human:<id>`, " \
430
+ "`process:<id>`)#{consequence || actor_consequence(actor)}",
431
+ metric: { by: actor, field: field })
432
+ end
433
+ end
434
+ end
435
+
436
+ # §5.3 reads the tier off the `human:` prefix and nothing else, so an
437
+ # actor that carries it is already human-reviewed however malformed the
438
+ # id is — and the same report's trust stat says so. Telling that reader
439
+ # it "reads as machine-confirmed" contradicted the run they were reading
440
+ # and pointed the fix at a tier that was never wrong; the id is what
441
+ # needs the edit.
442
+ def actor_consequence(actor)
443
+ return " — the `human:` prefix already reads as human-reviewed, but the id is not a bare token" \
444
+ if actor.start_with?(Concept::HUMAN_ACTOR)
445
+
446
+ " and reads as machine-confirmed; use `human:<id>` if a person confirmed this"
447
+ end
448
+
449
+ # §5.3 never reads `generated.by`, so unlike a verified actor nothing is
450
+ # misclassified — the cost is the audit trail: nobody downstream can say
451
+ # what kind of actor produced the text.
452
+ def generated_consequence
453
+ " — no trust tier reads it (§5.3 keys off verified), but a reader cannot tell a person from a process"
454
+ end
455
+
456
+ # §9 describes the log as date-grouped entries, newest first — prose, not
457
+ # an RFC keyword, so disorder is curation slack rather than a §11 error:
458
+ # exactly lint's side of the split. Only shape-valid headings are
459
+ # compared; a malformed date is already the validator's error, and
460
+ # double-reporting it here as disorder would name one defect twice.
461
+ def check_log_order
462
+ @bundle.log_files.each do |path|
463
+ dates = @bundle.reserved_content(path).each_line
464
+ .select { |line| line.start_with?("## ") }
465
+ .map { |line| line.sub(/\A## /, "").strip }
466
+ .grep(/\A\d{4}-\d{2}-\d{2}\z/)
467
+ next if dates == dates.sort.reverse
468
+
469
+ add(:log_order, path,
470
+ "date headings are not newest-first (§9): #{dates.join(", ")}",
471
+ metric: { dates: dates })
472
+ end
473
+ end
474
+
475
+ # ── Attestation (§10) ────────────────────────────────────────────────────────
476
+
477
+ # `incomplete_computation` asks whether the contract *names* its
478
+ # computation; this asks whether what it names is there. A §10 contract
479
+ # is an instruction to run something, so a path resolving to nothing is
480
+ # not slack the way an uncited source is — it is a contract no consumer
481
+ # can follow, which is why it warns like `broken_source` rather than
482
+ # informing. Same exemption by construction as `broken_source`: Links.resolve
483
+ # yields nil for a URL or a non-`.md` asset, so a `references/*.sql`
484
+ # computation and an `https://` runbook are both silently fine.
485
+ def check_broken_attestation_ref
486
+ @concepts.each do |concept|
487
+ # Gated on the type, like check_incomplete_computation: §4.1 lets a
488
+ # producer put `computation:` or `executor:` on anything and mean
489
+ # their own thing by it, and §10 governs those keys only here. An
490
+ # ungated check would fail a --fail-on warn gate over a key it has
491
+ # no standing to read.
492
+ next unless concept.attested_computation?
493
+
494
+ attestation_refs(concept).each do |field, raw|
495
+ target = Markdown::Links.resolve(raw.to_s, from: concept.path, bundle: @bundle.root)
496
+ next if target.nil? || @existing.include?(target)
497
+
498
+ add(:broken_attestation_ref, "#{concept.id}.md",
499
+ "#{field} `#{raw}` does not exist in the bundle", metric: { field: field, target: target })
500
+ end
501
+ end
502
+ end
503
+
504
+ # The §10 fields that name a file: `computation` (§10.3's path form) and
505
+ # the `resource` of `executor`/`attester` (§10.2), in the order a reader
506
+ # meets them so two dangling paths on one concept report predictably.
507
+ # #executor/#attester are nil unless the value is a mapping, so a
508
+ # malformed `executor: <path>` reads as absent here — its shape is the
509
+ # validator's finding, not a second report of the same defect.
510
+ def attestation_refs(concept)
511
+ [
512
+ [ "computation", concept.computation ],
513
+ [ "executor.resource", concept.executor && concept.executor["resource"] ],
514
+ [ "attester.resource", concept.attester && concept.attester["resource"] ]
515
+ ]
516
+ end
517
+
518
+ # §10.2/§10.3: the computation is provided exactly one way — a body
519
+ # `# Computation` fence *or* a `computation` path ("used instead of").
520
+ # Neither is a contract with nothing to run; both is two candidate
521
+ # computations and no rule for which one was sanctioned. Missing `runtime`
522
+ # is the validator's (shape/REQUIRED-within is its side of the split).
523
+ def check_incomplete_computation
524
+ @concepts.each do |concept|
525
+ next unless concept.attested_computation?
526
+
527
+ inline = computation_heading?(concept.body)
528
+ declared = !OKF.blank?(concept.computation)
529
+ if !inline && !declared
530
+ add(:incomplete_computation, "#{concept.id}.md",
531
+ "Attested Computation with no computation (neither a computation: path nor a # Computation section)",
532
+ metric: { provided: [] })
533
+ elsif inline && declared
534
+ add(:incomplete_computation, "#{concept.id}.md",
535
+ "Attested Computation provides its computation twice (§10.3: a computation: path is used " \
536
+ "instead of a # Computation section — keep one)",
537
+ metric: { provided: %w[computation body] })
230
538
  end
231
539
  end
232
540
  end
233
541
 
542
+ # ── Migration (§13.1) ────────────────────────────────────────────────────────
543
+
544
+ # What replaces a version gate. An operator running lint on a v0.1 bundle is
545
+ # *told*, in the tool that already exists for telling them things, and is
546
+ # never blocked — both are info, because §13 says the bundle is consumable
547
+ # forever and `--fail-on warn` must not turn red on it. A migration
548
+ # campaign gates explicitly: `--only legacy_timestamp,legacy_citations
549
+ # --fail-on info`.
550
+ #
551
+ # One finding per *bundle*, not per concept: a v0.1 bundle is v0.1 in every
552
+ # file, so a per-concept finding would print the same sentence once per
553
+ # document. `path` is nil and the members live in `metric`, exactly as
554
+ # :disconnected_component and :duplicate_title do.
555
+ def check_legacy_timestamp
556
+ report_legacy(:legacy_timestamp,
557
+ "the retired v0.1 `timestamp`; move the value under `generated: { by: <actor>, at: <the timestamp> }` " \
558
+ "— the actor is yours to supply, no tool can derive it", &:legacy_timestamp?)
559
+ end
560
+
561
+ def check_legacy_citations
562
+ report_legacy(:legacy_citations,
563
+ "the retired v0.1 `# Citations` section; move provenance into a `sources:` list " \
564
+ "(`resource` plus optional `id`/`title`), key claims with `[^id]` footnotes, then delete the section",
565
+ &:legacy_citations?)
566
+ end
567
+
234
568
  # ── Hygiene ────────────────────────────────────────────────────────────────
235
569
 
236
570
  def check_duplicate_title
237
571
  @concepts.group_by { |concept| concept.title.to_s.strip.downcase }.each do |key, members|
238
572
  next if key.empty? || members.size < 2
239
573
 
240
- @report.add_info(:duplicate_title, nil,
574
+ add(:duplicate_title, nil,
241
575
  "title #{members.first.title.inspect} used by #{members.size} concepts",
242
576
  metric: { title: members.first.title, concepts: members.map(&:id).sort })
243
577
  end
@@ -247,7 +581,7 @@ module OKF
247
581
  @concepts.each do |concept|
248
582
  defined = Markdown::Links.reference_definitions(concept.body).keys
249
583
  (defined - reference_uses(concept.body)).each do |label|
250
- @report.add_info(:unused_reference_def, "#{concept.id}.md",
584
+ add(:unused_reference_def, "#{concept.id}.md",
251
585
  "reference definition `[#{label}]` is defined but never used", metric: { label: label })
252
586
  end
253
587
  end
@@ -257,7 +591,7 @@ module OKF
257
591
  @concepts.each do |concept|
258
592
  defined = Markdown::Links.reference_definitions(concept.body).keys
259
593
  (reference_uses(concept.body) - defined).each do |label|
260
- @report.add_warning(:undefined_reference, "#{concept.id}.md",
594
+ add(:undefined_reference, "#{concept.id}.md",
261
595
  "reference-style link `[#{label}]` has no matching definition (an invisible broken link)",
262
596
  metric: { label: label })
263
597
  end
@@ -272,7 +606,7 @@ module OKF
272
606
  end
273
607
  next if count.zero?
274
608
 
275
- @report.add_info(:self_link, "#{concept.id}.md", "concept links to itself", metric: { count: count })
609
+ add(:self_link, "#{concept.id}.md", "concept links to itself", metric: { count: count })
276
610
  end
277
611
  end
278
612
 
@@ -284,19 +618,48 @@ module OKF
284
618
  @report.stat(:indexes, @bundle.index_files.size)
285
619
  @report.stat(:logs, @bundle.log_files.size)
286
620
  @report.stat(:skipped, @bundle.unparseable.size)
621
+ @report.stat(:skipped_checks, skipped_checks)
287
622
  @report.stat(:orphans, count_findings(:orphan))
288
623
  @report.stat(:loose, count_findings(:unlinked))
289
624
  @report.stat(:stubs, count_findings(:stub))
290
625
  @report.stat(:backlog, count_findings(:missing_concept))
291
626
  @report.stat(:components, components.size)
292
627
  @report.stat(:hubs, hubs)
293
- # Through Graph.default, not a second `|| "Untyped"`: §9.2 makes a
628
+ # Through Graph.default, not a second `|| "Untyped"`: §11.2 makes a
294
629
  # whitespace-only type as non-conformant as a missing one, so the two must
295
630
  # land in one bucket. Spelling the rule twice is how lint came to report a
296
631
  # `" "` bucket that `types` and `graph` had never heard of — the same
297
632
  # concepts counted by both verbs, into inventories that will not reconcile.
298
633
  @report.stat(:types, frequency(@concepts.map { |c| Graph.default(c.type, "Untyped") }))
299
634
  @report.stat(:tags, frequency(@concepts.flat_map { |c| c.tags.is_a?(Array) ? c.tags : [] }))
635
+ @report.stat(:trust, trust_distribution)
636
+ # Folded through the same rule --status narrows by, or the posture
637
+ # inventory cannot reconcile with the filter that acts on it.
638
+ @report.stat(:status, frequency(@concepts.map { |c| Concept.effective_status(c.declared_status) }))
639
+ end
640
+
641
+ # The clock-gated checks that were selected and could not run — named,
642
+ # never silent: a gate that is sometimes absent and does not confess
643
+ # converts "unchecked" into "checked and fine". This also makes :stale
644
+ # honest; it has been silently opt-in since it shipped.
645
+ def skipped_checks
646
+ skipped = []
647
+ selected = selected_checks
648
+ skipped << :expired if selected.include?(:expired) && @today.nil?
649
+ skipped << :stale if selected.include?(:stale) && @stale_before.nil?
650
+ skipped
651
+ end
652
+
653
+ # §5.3's three tiers, counted, in the wire spelling the rows use. A
654
+ # per-concept `unverified` *check* would fire on every concept of every
655
+ # bundle that has not adopted `verified`, so the posture is a distribution
656
+ # instead of noise. All three keys are always present, zeroes included: a
657
+ # missing key reads as "not measured" where 0 reads as "none", and only
658
+ # one of those is true.
659
+ def trust_distribution
660
+ counts = { "unverified" => 0, "machine-confirmed" => 0, "human-reviewed" => 0 }
661
+ @concepts.each { |concept| counts[concept.trust] += 1 }
662
+ counts
300
663
  end
301
664
 
302
665
  # ── helpers ────────────────────────────────────────────────────────────────
@@ -305,7 +668,7 @@ module OKF
305
668
  @concepts.each do |concept|
306
669
  next unless OKF.blank?(concept.public_send(field))
307
670
 
308
- @report.add_info(check, "#{concept.id}.md", "missing recommended field: #{label}")
671
+ add(check, "#{concept.id}.md", "missing recommended field: #{label}")
309
672
  end
310
673
  end
311
674
 
@@ -382,14 +745,74 @@ module OKF
382
745
  uses = []
383
746
  Markdown::Links.each_prose_line(body.to_s) do |line|
384
747
  line.scan(Markdown::Links::REFERENCE_LINK).each do |label, explicit|
385
- uses << (explicit.empty? ? label : explicit).strip.downcase
748
+ key = (explicit.empty? ? label : explicit).strip.downcase
749
+ # A caret label is footnote space (§5.1) — the exclusion DEFINITION
750
+ # already makes. Adjacent footnotes (`[^a][^b]`) match the
751
+ # reference-link grammar, and counting them here turned a
752
+ # well-formed document into an undefined_reference warn.
753
+ uses << key unless key.start_with?("^")
386
754
  end
387
755
  end
388
756
  uses.uniq
389
757
  end
390
758
 
759
+ def source_ids(concept)
760
+ concept.sources.map { |source| source["id"].to_s.strip }.reject(&:empty?)
761
+ end
762
+
763
+ # One body scan per concept, however many joins ask — the two keyed-
764
+ # attribution checks read the same labels, and the scan is a real
765
+ # per-line pass (CODE_SPAN blanking included).
766
+ def footnote_labels(concept)
767
+ @footnote_labels ||= {}
768
+ # Keyed on the path, which is unique by construction — two files may
769
+ # pin the same custom `id`, and an id-keyed cache cross-contaminated
770
+ # their joins.
771
+ @footnote_labels[concept.path] ||= Markdown::Links.footnote_references(concept.body)
772
+ end
773
+
774
+ # Whether the body carries a §10.3 `# Computation` section, fence-aware.
775
+ # §10.3's inline form is "a single fenced code block in the body under
776
+ # `# Computation`" — so inline is proven by the fence, not the heading. A
777
+ # heading over prose used to count as provided, and a contract with
778
+ # nothing an executor could run lint'd clean. Walked raw (not through
779
+ # each_prose_line, which blanks fences): a fence line while the section
780
+ # is open is the answer, and a heading inside an earlier fence is text.
781
+ def computation_heading?(body)
782
+ in_fence = false
783
+ in_section = false
784
+ body.to_s.each_line do |line|
785
+ stripped = line.strip
786
+ if stripped.start_with?("```", "~~~")
787
+ return true if in_section && !in_fence
788
+
789
+ in_fence = !in_fence
790
+ next
791
+ end
792
+ next if in_fence
793
+
794
+ if COMPUTATION_HEADING.match?(stripped)
795
+ in_section = true
796
+ elsif in_section && stripped.match?(/\A\#{1,6}\s/)
797
+ in_section = false
798
+ end
799
+ end
800
+ false
801
+ end
802
+
803
+ # One bundle-level finding naming how many concepts carry a retired
804
+ # spelling, with the file list in the metric for whatever will rewrite them.
805
+ def report_legacy(check, description, &spelling)
806
+ members = @concepts.select(&spelling).map { |concept| "#{concept.id}.md" }
807
+ return if members.empty?
808
+
809
+ add(check, nil,
810
+ "#{members.size} concept(s) still use #{description}",
811
+ metric: { concepts: members.sort })
812
+ end
813
+
391
814
  def external?(raw)
392
- raw.match?(Markdown::Links::SCHEME) || raw.start_with?("mailto:")
815
+ raw.match?(Markdown::Links::SCHEME) || raw.match?(Markdown::Links::MAILTO)
393
816
  end
394
817
 
395
818
  def count_findings(check)